Compare commits

..

37 Commits

Author SHA1 Message Date
Bijin A B
6dc76e023a fix: update unit tests 2025-12-09 12:00:20 +05:30
Anoop M D
2c34844172 chore: addressed coderabbit review comments 2025-12-09 04:17:03 +05:30
Anoop M D
2f8986624a feat: toolbar design updates 2025-12-09 04:05:50 +05:30
Abhishek S Lal
0197ae37c8 refactor: update AppTitleBar and SidebarHeader components (#6341)
* refactor: update AppTitleBar and SidebarHeader components to use MenuDropdown and ActionIcon for improved UI consistency

* refactor: update button locators in tests to use data-testid for consistency and improved readability
2025-12-08 22:06:24 +05:30
Dániel Seres
cf969dfcd6 fix: Support @contentType for multiline values (#6217)
* fix: Support @contentType for multiline values

Fixes the issue where the @contentType annotation broke the parsing of multiline values.

* chore: add dotall flag to fileExtractContentType

Not strictly needed since body:file uses single-line values in practice,
but doesn't hurt and matches what multipartExtractContentType does.

---------

Co-authored-by: Márk Dániel Seres <markdaniel.seres@tesco.com>
2025-12-08 18:39:25 +05:30
Pragadesh-45
a66be21523 feat: Enhance runCollectionFolder to support selected request ordering (#6320)
* Refactor `runCollectionFolder` action to accept `selectedRequestUids` for filtering and ordering requests.
* Update IPC handler to process `selectedRequestUids`, ensuring requests are executed in the specified order while preserving folder data.
2025-12-08 17:16:24 +05:30
naman-bruno
4016754d71 feat: integrate import/export modals and refactor environment handling (#6346) 2025-12-08 15:17:53 +05:30
Anoop M D
f3aebf6374 Merge pull request #6345 from usebruno/feat/design-updates
feat: design updates
2025-12-08 14:42:06 +05:30
naman-bruno
f87460b00e refactor: simplify last opened workspaces management by removing workspace config from storage and improving path handling (#6343) 2025-12-08 14:30:16 +05:30
naman-bruno
354e8d7496 feat: add hideApiSpecPage dispatch to Collection and CollectionItem components (#6344) 2025-12-08 14:24:47 +05:30
Anoop M D
dc107f8b96 init (#6337) 2025-12-08 01:37:16 +05:30
naman-bruno
cd0f1e45ba init 2025-12-07 21:53:47 +05:30
Bijin A B
33022843f2 fix: CWE-347: Improper Verification of Cryptographic Signature (#6336) 2025-12-07 14:16:39 +05:30
Anoop M D
facdf3264a feat: changes to incorporate oc schema updates (#6335)
* feat: changes to incorporate oc schema updates
* chore: fixed oc types resolution issue
2025-12-07 06:05:48 +05:30
naman-bruno
4ffb447c53 fix: path for newly added collection & remove option for outside collections (#6331)
* fixes

* fixes

* fix
2025-12-06 18:43:53 +05:30
Sanjai Kumar
3e5ae613f5 feat: Increase visibility of text in Request tabs (#6243)
* refactor(RequestTabs): update tab width calculation and improve styling

* refactor: replace close icon implementation with GradientCloseButton and adjust styles

* changes: design

* fix: failing tests

* fixes

* fixes: coderabbit

* fixes

* fixes

* gradient color fix

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-06 18:42:57 +05:30
naman-bruno
42bef4ae1e fix: traffic light styling on light mode (#6333)
* fix

* fixes
2025-12-06 18:16:37 +05:30
naman-bruno
e93e545b81 improve: tests (#6321)
* improve: tests

* fixes

* fixes
2025-12-06 15:36:58 +05:30
Abhishek S Lal
4a8d787f31 feat: Moved Workspace Selector to the Titlebar of the window. (#6319)
* refactor: update sidebar components and styles, replace TitleBar with SidebarHeader, and enhance collections search functionality

* refactor: improve event listener management in AppTitleBar and clean up SidebarHeader styles

* fix: ensure safe access to layout preferences in AppTitleBar and set default order in SidebarHeader

* refactor: centralize toTitleCase utility and remove redundant implementations in AppTitleBar and WorkspaceSelector

* feat: enhance accessibility and testing for sidebar and devtools toggle buttons in AppTitleBar

* chore: quick fix on a flaky test

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-06 02:07:05 +05:30
Bijin A B
f5211f6a08 Update quotes rule for string in CODING_STANDARDS.md (#6327) 2025-12-06 02:01:03 +05:30
Sanjai Kumar
57222d2500 feat: enhance collection settings with environment modals (#6242)
* feat: enhance collection settings with environment modals

* refactor: remove unused environment modal state and simplify Info component structure
2025-12-05 19:20:04 +05:30
Sanjai Kumar
f479e0d325 refactor: Rename runtime to runDuration (#6323)
* refactor: Rename 'runtime' to 'runDuration'

* revert changes made in report.html
2025-12-05 19:17:06 +05:30
naman-bruno
5302addda0 fix: clone collection (#6322)
* fix: clone collection
2025-12-05 17:26:06 +05:30
Sanjai Kumar
80b017f224 feat: Include pre-request and post-response tests in JUnit reports (#6284)
* enhance: JUnit output to include preRequest and postResponse test results

* fix: lint
2025-12-05 12:04:32 +05:30
Bijin A B
b18d582004 Merge pull request #6310 from sanjaikumar-bruno/chore/eslint-ignore-paths
chore: update ESLint configuration to ignore additional directories
2025-12-04 19:23:25 +05:30
Bijin A B
109394c65b Merge pull request #6308 from Pragadesh-45/fix/6254
feat: Streamline gRPC requests to use right context
2025-12-04 18:58:10 +05:30
Sid
c355153f26 Revert: Re-add post response vars (#6307)
* Partial Revert "remove: presets and response var (#6195)"

This reverts commit 786a3414b8 while keeping code related to presets deleted

* revert: remove global environment variables assignment
2025-12-04 18:04:47 +05:30
Pragadesh-45
b87a02beb3 feat: Streamline gRPC requests to use right context 2025-12-04 18:00:32 +05:45
sanjai
4624ffb116 chore: update ESLint configuration to ignore additional directories 2025-12-04 16:34:48 +05:30
Sid
a9ce97fb1b fix: update content security policy to remove unsafe-inline (#6305) 2025-12-04 12:40:52 +05:30
Pooja
72ce6cadeb fix: request and response pane height (#6294) 2025-12-04 12:31:57 +05:30
Bijin A B
c4ff2918a2 Merge pull request #6080 from dssagar93/feature/auto-scroll-on-tab-change
Auto scroll to show this item when its tab becomes active
2025-12-04 10:05:32 +05:30
Bijin A B
9972eb3de6 Merge branch 'main' of github.com:usebruno/bruno into feature/auto-scroll-on-tab-change 2025-12-04 05:15:39 +05:30
naman-bruno
ebe0203415 init: workspaces (#6264)
* init: workspaces
2025-12-04 04:56:43 +05:30
SAGAR KHATRI
f7ea1f8dbb Added semi colon 2025-11-13 19:31:21 +05:30
SAGAR KHATRI
cf19035b0b Merge branch 'usebruno:main' into feature/auto-scroll-on-tab-change 2025-11-12 23:53:05 +05:30
SAGAR KHATRI
d9a3f74cb7 Auto scroll to show this item when its tab becomes active 2025-11-12 20:50:44 +05:30
218 changed files with 8414 additions and 2949 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,8 @@
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"js-yaml": "^4.1.0",
"xml2js": "^0.6.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
@@ -83,6 +85,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "5.17.12",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",

View File

@@ -0,0 +1,129 @@
const yamlPlugin = (cm) => {
cm.defineMode('yaml', function () {
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
var keywordRegex = new RegExp('\\b((' + cons.join(')|(') + '))$', 'i');
return {
token: function (stream, state) {
var ch = stream.peek();
var esc = state.escaped;
state.escaped = false;
/* comments */
if (ch == '#' && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
stream.skipToEnd();
return 'comment';
}
if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return 'string';
if (state.literal && stream.indentation() > state.keyCol) {
stream.skipToEnd();
return 'string';
} else if (state.literal) {
state.literal = false;
}
if (stream.sol()) {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
/* document start */
if (stream.match('---')) {
return 'def';
}
/* document end */
if (stream.match('...')) {
return 'def';
}
/* array list item */
if (stream.match(/\s*-\s+/)) {
return 'meta';
}
}
/* inline pairs/lists */
if (stream.match(/^(\{|\}|\[|\])/)) {
if (ch == '{') state.inlinePairs++;
else if (ch == '}') state.inlinePairs--;
else if (ch == '[') state.inlineList++;
else state.inlineList--;
return 'meta';
}
/* list separator */
if (state.inlineList > 0 && !esc && ch == ',') {
stream.next();
return 'meta';
}
/* pairs separator */
if (state.inlinePairs > 0 && !esc && ch == ',') {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
stream.next();
return 'meta';
}
/* start of value of a pair */
if (state.pairStart) {
/* block literals */
if (stream.match(/^\s*(\||\>)\s*/)) {
state.literal = true;
return 'meta';
}
/* references */
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) {
return 'variable-2';
}
/* numbers */
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) {
return 'number';
}
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) {
return 'number';
}
/* keywords */
if (stream.match(keywordRegex)) {
return 'keyword';
}
}
/* pairs (associative arrays) -> key */
if (
!state.pair
&& stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)
) {
state.pair = true;
state.keyCol = stream.indentation();
return 'atom';
}
if (state.pair && stream.match(/^:\s*/)) {
state.pairStart = true;
return 'meta';
}
/* nothing found, continue */
state.pairStart = false;
state.escaped = ch == '\\';
stream.next();
return null;
},
startState: function () {
return {
pair: false,
pairStart: false,
keyCol: 0,
inlinePairs: 0,
inlineList: 0,
literal: false,
escaped: false
};
},
lineComment: '#',
fold: 'indent'
};
});
cm.defineMIME('text/x-yaml', 'yaml');
cm.defineMIME('text/yaml', 'yaml');
};
export default yamlPlugin;

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: calc(100vh - 4rem);
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
line-break: anywhere;
}
.CodeMirror-dialog {
overflow: visible;
input {
background: transparent;
border: 1px solid #d3d6db;
outline: none;
border-radius: 0px;
}
}
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #d2d7db;
}
textarea.cm-editor {
position: relative;
}
// Todo: dark mode temporary fix
// Clean this
.CodeMirror.cm-s-monokai {
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #444444;
}
}
.cm-s-monokai span.cm-property,
.cm-s-monokai span.cm-attribute {
color: #9cdcfe !important;
}
.cm-s-monokai span.cm-string {
color: #ce9178 !important;
}
.cm-s-monokai span.cm-number {
color: #b5cea8 !important;
}
.cm-s-monokai span.cm-atom {
color: #569cd6 !important;
}
.cm-variable-valid {
color: green;
}
.cm-variable-invalid {
color: red;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import StyledWrapper from './StyledWrapper';
import yamlPlugin from './Plugins/Yaml/index';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
this.cachedValue = props.value || '';
this.variables = {};
this.lintOptions = {
esversion: 11,
expr: true,
asi: true
};
}
componentWillMount() {
switch (this.props.mode) {
case 'yaml':
// YAML linting and hightlighting plugin
yamlPlugin(CodeMirror);
break;
default:
break;
}
}
componentDidMount() {
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
mode: this.props.mode || 'application/text',
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
showCursorWhenSelecting: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
lint: this.lintOptions,
readOnly: this.props.readOnly,
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
: cm.replaceSelection(' ', 'end');
},
'Shift-Tab': 'indentLess',
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll'
}
}));
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
}
}
componentDidUpdate(prevProps) {
this.ignoreChangeEvent = true;
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor = null;
}
}
render() {
if (this.editor) {
this.editor.refresh();
}
return (
<StyledWrapper
className="h-full w-full graphiql-container"
aria-label="Code Editor"
font={this.props.font}
ref={(node) => {
this._node = node;
}}
/>
);
}
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
this.cachedValue = this.editor.getValue();
if (this.props.onEdit) {
this.props.onEdit(this.cachedValue);
}
}
};
}

View File

@@ -0,0 +1,51 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { IconDeviceFloppy } from '@tabler/icons';
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
const FileEditor = ({ apiSpec }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [content, setContent] = useState(apiSpec?.raw);
const onEdit = (value) => {
setContent(value);
};
const onSave = () => {
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
};
const hasChanges = Boolean(content != apiSpec?.raw);
const editorMode = 'yaml';
return (
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={content}
onEdit={onEdit}
onSave={onSave}
mode={editorMode}
font={get(preferences, 'font.codeFont', 'default')}
/>
<IconDeviceFloppy
onClick={onSave}
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
}`}
/>
</div>
);
};
export default FileEditor;

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.swagger-root {
height: calc(100vh - 4rem);
border: solid 1px ${(props) => props.theme.codemirror.border};
&.dark {
.swagger-ui {
filter: invert(88%) hue-rotate(180deg);
}
.swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,19 @@
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const Swagger = ({ string }) => {
const { displayedTheme } = useTheme();
console.log('string', string);
return (
<StyledWrapper>
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
<SwaggerUI spec={string} />
</div>
</StyledWrapper>
);
};
export default Swagger;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.menu-icon {
cursor: pointer;
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
}
div.dropdown-item.menu-item {
color: ${(props) => props.theme.colors.danger};
&:hover {
background-color: ${(props) => props.theme.colors.bg.danger};
color: white;
}
}
.react-tooltip {
z-index: 10;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,97 @@
import React, { forwardRef, useRef } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import FileEditor from './FileEditor';
import Dropdown from 'components/Dropdown';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import { Suspense } from 'react';
import Swagger from './Renderers/Swagger';
import toast from 'react-hot-toast';
const ApiSpecPanel = () => {
const dispatch = useDispatch();
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
const { apiSpecs, activeApiSpecUid } = useSelector((state) => state.apiSpec);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid } = apiSpec || {};
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref}>
<IconDots size={22} />
</div>
);
});
const handleOpenApiSpec = () => {
dispatch(openApiSpec()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
);
};
return (
<StyledWrapper className="flex flex-col flex-grow relative">
{createApiSpecModalOpen ? <CreateApiSpec onClose={() => setCreateApiSpecModalOpen(false)} /> : null}
<div className="p-3 mb-2 w-full flex flex-row justify-between grid grid-cols-3">
<div className="flex flex-row justify-start gap-x-4 col-span-1">
<div className="flex w-fit items-center cursor-pointer">
<IconFileCode size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">API Designer</span>
</div>
</div>
<div className="w-full col-span-1 flex justify-center" title={pathname}>
{filename}
</div>
<div className="menu-icon pr-2 col-span-1 flex justify-end">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCreateApiSpecModalOpen(true);
}}
>
Create API Spec
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleOpenApiSpec();
}}
>
Open API Spec
</div>
</Dropdown>
</div>
</div>
<section className="main flex flex-grow px-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<FileEditor apiSpec={apiSpec} />
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger string={raw} />
</Suspense>
</div>
</div>
</section>
</StyledWrapper>
);
};
export default ApiSpecPanel;

View File

@@ -0,0 +1,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;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.api-specs-badge {
margin-inline: 0.5rem;
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 5px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,21 @@
import { IconFileCode } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ApiSpecsBadge = () => {
return (
<StyledWrapper>
<div className="items-center mt-2 relative">
<div className="api-specs-badge flex items-center justify-between px-2">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFileCode size={18} strokeWidth={1.5} />
</span>
<span>APIs</span>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default ApiSpecsBadge;

View File

@@ -0,0 +1,64 @@
import { setActiveApiSpecUid } from 'providers/ReduxStore/slices/apiSpec';
import { showApiSpecPage as _showApiSpecPage } from 'providers/ReduxStore/slices/app';
import Dropdown from 'components/Dropdown';
import { IconDots } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import CloseApiSpec from '../CloseApiSpec/index';
import { forwardRef } from 'react';
const ApiSpecItem = ({ apiSpec }) => {
const dispatch = useDispatch();
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const [closeApiSpecModal, setCloseApiSpecModal] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const handleOpenApiSpec = (apiSpec) => (e) => {
dispatch(_showApiSpecPage());
dispatch(setActiveApiSpecUid({ uid: apiSpec.uid }));
};
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref}>
<IconDots size={22} />
</div>
);
});
return (
<div
className={`flex flex-grow api-spec-item items-center h-full overflow-hidden w-full justify-between ${
showApiSpecPage && apiSpec?.uid == activeApiSpecUid ? 'active' : ''
}`}
>
{closeApiSpecModal && <CloseApiSpec apiSpec={apiSpec} onClose={() => setCloseApiSpecModal(false)} />}
<div
className="cursor-pointer py-2 pl-4 h-8 flex items-center flex-grow w-[80%] justify-between"
onClick={handleOpenApiSpec(apiSpec)}
>
<span className="flex-nowrap whitespace-nowrap overflow-ellipsis overflow-hidden w-full">{apiSpec?.name}</span>
</div>
<div className="menu-icon pr-2">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item close-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCloseApiSpecModal(true);
}}
>
Close
</div>
</Dropdown>
</div>
</div>
);
};
export default ApiSpecItem;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { IconFileCode } from '@tabler/icons';
import { closeApiSpecFile } from 'providers/ReduxStore/slices/apiSpec';
const CloseApiSpec = ({ onClose, apiSpec }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(closeApiSpecFile({ uid: apiSpec.uid }))
.then(() => {
toast.success('API Spec closed');
onClose();
})
.catch(() => toast.error('An error occurred while closing the API Spec'));
};
return (
<Modal size="sm" title="Close Api Spec" confirmText="Close" handleConfirm={onConfirm} handleCancel={onClose}>
<div className="flex items-center">
<IconFileCode size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{apiSpec.name}</span>
</div>
<div className="break-words text-xs mt-1">{apiSpec.pathname}</div>
<div className="mt-4">
Are you sure you want to close API Spec <span className="font-semibold">{apiSpec.name}</span> in Bruno?
</div>
<div className="mt-4">
It will still be available in the file system at the above location and can be re-opened later.
</div>
</Modal>
);
};
export default CloseApiSpec;

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.api-spec-file-extension {
color: ${(props) => props.theme.colors.text.darkOrange};
}
select {
background: ${(props) => props.theme.bg};
}
option {
background: ${(props) => props.theme.bg};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,326 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { createApiSpecFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { exportApiSpec } from 'utils/exporters/openapi-spec';
import { each } from 'lodash';
import { showApiSpecPage } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
export const getEnvironmentVariablesKeyValuePairs = (envVariables) => {
let variables = {};
each(envVariables, (variable) => {
if (variable.name && variable.value && variable.enabled) {
variables[variable.name] = variable.value;
}
});
return variables;
};
const CreateApiSpec = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const [defaultApiSpecLocation, setDefaultApiSpecLocation] = React.useState('');
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
React.useEffect(() => {
const getDefaultLocation = async () => {
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
try {
const { ipcRenderer } = window;
const apiSpecPath = await ipcRenderer.invoke('renderer:ensure-apispec-folder', activeWorkspace.pathname);
setDefaultApiSpecLocation(apiSpecPath);
} catch (error) {
console.error('Error getting apispec folder:', error);
}
}
};
getDefaultLocation();
}, [activeWorkspace]);
const formik = useFormik({
enableReinitialize: true,
initialValues: {
importFrom: 'blank',
collectionLocation: '',
environment: '',
apiSpecName: '',
apiSpecLocation: defaultApiSpecLocation || ''
},
validationSchema: Yup.object({
importFrom: Yup.string().oneOf(['blank', 'collection']),
collectionLocation: Yup.string().min(1, 'location is required'),
environment: Yup.string(),
apiSpecName: Yup.string()
.min(1, 'Must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required'),
apiSpecLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
onSubmit: async (values) => {
let yamlContent = '';
if (values?.importFrom === 'collection' && values?.collectionLocation && collectionData) {
const { files, envVariables, processEnvVariables } = collectionData;
let variables = {
processEnvVariables
};
// Get selected env's variables
if (values?.environment && values?.environment?.length) {
variables = {
...getEnvironmentVariablesKeyValuePairs(envVariables[values?.environment] || {}),
...variables
};
}
// Create API spec yaml
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
if (exportedYamlContentData?.content) {
yamlContent = exportedYamlContentData?.content;
}
}
dispatch(createApiSpecFile(`${values.apiSpecName}.yaml`, values.apiSpecLocation, yamlContent))
.then(() => {
setTimeout(() => {
dispatch(showApiSpecPage());
}, 200);
toast.success('ApiSpec created');
onClose();
})
.catch((err) => toast.error(err?.message));
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
// When the user closes the diolog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('apiSpecLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('apiSpecLocation', '');
console.error(error);
});
};
const browseCollection = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const [environments, setEnvironments] = useState([]);
const [collectionData, setCollectionData] = useState(null);
useEffect(() => {
const collectionLocation = formik.values.collectionLocation;
if (collectionLocation) {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:get-collection-json', collectionLocation)
.then(({ files, name, envVariables, processEnvVariables }) => {
setCollectionData({ name, files, envVariables, processEnvVariables });
const environments = envVariables || {};
const environmentNames = Object.keys(environments);
if (environmentNames?.length) {
setEnvironments(environments);
formik.setFieldValue('environment', environmentNames[0] || '');
}
})
.catch((err) => {
console.error('Error loading collection:', err);
toast.error('Failed to load collection');
});
}
}, [formik.values.collectionLocation]);
const onSubmit = () => formik.handleSubmit();
return (
<StyledWrapper>
<Modal size="sm" title="Create API Spec" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="api-spec-location" className="block font-semibold mb-2">
Template
</label>
<div className="flex items-center">
<input
id="blank"
className="cursor-pointer"
type="radio"
name="importFrom"
onChange={formik.handleChange}
value="blank"
checked={formik.values.importFrom === 'blank'}
/>
<label htmlFor="blank" className="ml-1 cursor-pointer select-none">
Blank spec
</label>
<input
id="collection"
className="ml-4 cursor-pointer"
type="radio"
name="importFrom"
onChange={formik.handleChange}
value="collection"
checked={formik.values.importFrom === 'collection'}
/>
<label htmlFor="collection" className="ml-1 cursor-pointer select-none">
From Bruno Collection
</label>
</div>
{formik.touched.importFrom && formik.errors.importFrom ? (
<div className="text-red-500">{formik.errors.importFrom}</div>
) : null}
{formik.values.importFrom === 'collection' ? (
<>
<label htmlFor="collection-location" className="block font-semibold mt-3">
Collection Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
title={formik.values.collectionLocation || ''}
value={formik.values.collectionLocation || ''}
onClick={browseCollection}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browseCollection}>
Browse
</span>
</div>
{environments && Object.keys(environments || {})?.length > 0 ? (
<>
<label htmlFor="api-spec-name" className="flex items-center font-semibold mt-3">
Environment
</label>
<div className="relative">
<select
value={formik.values.environment || ''}
onChange={(e) => {
formik.setFieldValue('environment', e.target.value);
}}
className="block textbox mt-2 w-full mousetrap"
>
{Object.keys(environments).map((env) => (
<option key={env} value={env}>
{env}
</option>
))}
</select>
</div>
</>
) : (
<></>
)}
</>
) : (
<></>
)}
{formik.touched.environment && formik.errors.environment ? (
<div className="text-red-500">{formik.errors.environment}</div>
) : null}
<label htmlFor="api-spec-name" className="flex items-center font-semibold mt-3">
Spec Name
</label>
<div className="relative">
<input
id="api-spec-name"
type="text"
name="apiSpecName"
ref={inputRef}
className="block textbox mt-2 !pr-11 w-full"
onChange={(e) => {
formik.handleChange(e);
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.apiSpecName || ''}
/>
<div className="absolute right-2 top-0 bottom-0 h-full flex items-center api-spec-file-extension">
.yaml
</div>
</div>
{formik.touched.apiSpecName && formik.errors.apiSpecName ? (
<div className="text-red-500">{formik.errors.apiSpecName}</div>
) : null}
<label htmlFor="api-spec-location" className="block font-semibold mt-3">
Spec Location
</label>
<input
id="api-spec-location"
type="text"
name="apiSpecLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
title={formik.values.apiSpecLocation || ''}
value={formik.values.apiSpecLocation || ''}
onClick={browse}
/>
{formik.touched.apiSpecLocation && formik.errors.apiSpecLocation ? (
<div className="text-red-500">{formik.errors.apiSpecLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
{!isDefaultWorkspace && (
<span className="text-xs opacity-60 ml-2">
(defaults to workspace's apispec folder)
</span>
)}
</div>
</div>
</form>
</Modal>
</StyledWrapper>
);
};
export default CreateApiSpec;

View File

@@ -0,0 +1,52 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.api-spec-item {
&.active {
background: ${(props) => props.theme.sidebar.collection.item.bg};
}
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
}
}
}
.menu-icon {
cursor: pointer;
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
}
}
}
div.tippy-box {
position: relative;
top: -0.625rem;
}
div.dropdown-item.close-item {
color: ${(props) => props.theme.colors.danger};
&:hover {
background-color: ${(props) => props.theme.colors.bg.danger};
color: white;
}
}
.placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import ApiSpecItem from './ApiSpecItem';
import ApiSpecsBadge from './ApiSpecBadge';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const LinkStyle = styled.span`
color: ${(props) => props.theme['text-link']};
`;
const ApiSpecs = () => {
const dispatch = useDispatch();
const { theme } = useTheme();
const allApiSpecs = useSelector((state) => state.apiSpec.apiSpecs);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const apiSpecs = React.useMemo(() => {
if (!activeWorkspace) return [];
const workspaceApiSpecs = activeWorkspace.apiSpecs || [];
// Map workspace API specs to loaded API specs from Redux store
return workspaceApiSpecs.map((ws) => {
const loadedApiSpec = allApiSpecs.find((apiSpec) => apiSpec.pathname === ws.path);
return loadedApiSpec;
}).filter(Boolean);
}, [allApiSpecs, activeWorkspace, activeWorkspace?.apiSpecs]);
const handleOpenApiSpec = () => {
dispatch(openApiSpec()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
);
};
const OpenLink = () => (
<LinkStyle className="underline text-link cursor-pointer" theme={theme} onClick={() => handleOpenApiSpec()}>
Open
</LinkStyle>
);
if (!apiSpecs || !apiSpecs.length) {
return (
<StyledWrapper>
<ApiSpecsBadge />
<div className="text-xs text-center placeholder mt-4">
<div>No API Specs found.</div>
<div className="mt-2">
<OpenLink /> API Spec.
</div>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<div className="relative">
<ApiSpecsBadge />
<div className="flex flex-col top-32 bottom-10 left-0 right-0 py-4">
{apiSpecs && apiSpecs.length
? apiSpecs.map((apiSpec) => {
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
})
: null}
</div>
</div>
</StyledWrapper>
);
};
export default ApiSpecs;

View File

@@ -21,7 +21,7 @@ const StyledWrapper = styled.div`
}
.collection-item-name {
height: 1.75rem;
height: 1.6rem;
cursor: pointer;
user-select: none;
position: relative;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'provide
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
const useIpcEvents = () => {
const dispatch = useDispatch();
@@ -91,10 +92,25 @@ const useIpcEvents = () => {
}
};
const _apiSpecTreeUpdated = (type, val) => {
if (window.__IS_DEV__) {
console.log('API Spec update:', type);
console.log(val);
}
if (type === 'addFile') {
dispatch(apiSpecAddFileEvent({ data: val }));
}
if (type === 'changeFile') {
dispatch(apiSpecChangeFileEvent({ data: val }));
}
};
ipcRenderer.invoke('renderer:ready');
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
dispatch(openCollectionEvent(uid, pathname, brunoConfig));
});
@@ -267,6 +283,7 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();
removeWorkspaceConfigUpdatedListener();

View File

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

View File

@@ -9,6 +9,7 @@ import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
import performanceReducer from './slices/performance';
import workspacesReducer from './slices/workspaces';
import apiSpecReducer from './slices/apiSpec';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
import { autosaveMiddleware } from './middlewares/autosave/middleware';
@@ -30,7 +31,8 @@ export const store = configureStore({
globalEnvironments: globalEnvironmentsReducer,
logs: logsReducer,
performance: performanceReducer,
workspaces: workspacesReducer
workspaces: workspacesReducer,
apiSpec: apiSpecReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});

View File

@@ -0,0 +1,162 @@
import { createSlice } from '@reduxjs/toolkit';
import { find } from 'lodash';
import toast from 'react-hot-toast';
const initialState = {
apiSpecs: [],
activeApiSpecUid: null
};
export const apiSpecSlice = createSlice({
name: 'apiSpec',
initialState,
reducers: {
apiSpecAddFileEvent: (state, action) => {
const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
if (!uid) {
toast.error('Error adding API spec');
}
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
if (apiSpec) {
apiSpec.raw = raw;
apiSpec.name = name;
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
} else {
const newApiSpec = {
name,
raw,
uid,
filename,
pathname,
json
};
state.apiSpecs.push(newApiSpec);
}
state.activeApiSpecUid = uid;
},
apiSpecChangeFileEvent: (state, action) => {
const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
if (!uid) return;
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
if (apiSpec) {
apiSpec.raw = raw;
apiSpec.name = name;
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
}
},
saveApiSpec: (state, action) => {
const { content, uid } = action.payload;
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
if (apiSpec) {
apiSpec.raw = content;
}
},
setActiveApiSpecUid: (state, action) => {
state.activeApiSpecUid = action.payload.uid;
},
removeApiSpec: (state, action) => {
const { uid } = action.payload;
let apiSpecIndex = state.apiSpecs.findIndex((c) => c.uid == uid);
state.apiSpecs = state.apiSpecs.filter((c) => c.uid !== uid);
let shiftedApiSpec = state.apiSpecs.at(apiSpecIndex);
let lastApiSpec = state.apiSpecs.at(-1);
state.activeApiSpecUid = shiftedApiSpec?.uid || lastApiSpec?.uid || null;
}
}
});
export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid } = apiSpecSlice.actions;
export default apiSpecSlice.reducer;
const findApiSpecByUid = (apiSpecs, uid) => {
return find(apiSpecs, (apiSpec) => apiSpec.uid === uid);
};
export const openApiSpec = (workspacePath = null) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
if (!workspacePath) {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
workspacePath = activeWorkspace?.pathname || null;
}
ipcRenderer.invoke('renderer:open-api-spec', workspacePath).then(resolve).catch(reject);
});
};
export const saveApiSpecToFile
= ({ uid, content }) =>
(dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const apiSpec = findApiSpecByUid(state.apiSpec.apiSpecs, uid);
const { pathname } = apiSpec;
ipcRenderer
.invoke('renderer:save-api-spec', pathname, content)
.then(() => {
dispatch(saveApiSpec({ content, uid }));
toast.success('Saved API spec successfully!');
resolve();
})
.catch((reject) => {
toast.error('Error saving file');
resolve();
});
});
};
export const createApiSpecFile = (apiSpecName, apiSpecLocation, content, workspacePath = null) => (dispatch, getState) => {
const { ipcRenderer } = window;
if (!workspacePath) {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
workspacePath = activeWorkspace?.pathname || null;
}
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:create-api-spec', apiSpecName, apiSpecLocation, content, workspacePath).then(resolve).catch(reject);
});
};
export const closeApiSpecFile
= ({ uid }) =>
(dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const apiSpec = findApiSpecByUid(state.apiSpec.apiSpecs, uid);
if (!apiSpec) {
return reject(new Error('API Spec not found'));
}
if (apiSpec) {
const { ipcRenderer } = window;
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const workspacePath = activeWorkspace?.pathname || null;
ipcRenderer
.invoke('renderer:remove-api-spec', apiSpec.pathname, workspacePath)
.then(async () => {
dispatch(removeApiSpec({ uid }));
if (activeWorkspace) {
const { loadWorkspaceApiSpecs } = require('./workspaces/actions');
await dispatch(loadWorkspaceApiSpecs(activeWorkspace.uid));
}
resolve();
})
.catch((error) => reject(error));
}
return;
});
};

View File

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

View File

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

View File

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

View File

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

View File

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