Compare commits

..

11 Commits

Author SHA1 Message Date
Bijin A B
ae8851385f Merge branch 'main' of usebruno/bruno into workspaces 2025-12-04 04:17:45 +05:30
naman-bruno
77d2fecfe6 fixes 2025-12-03 15:02:45 +05:30
naman-bruno
663b06d60f fix blank line 2025-12-03 14:11:25 +05:30
naman-bruno
dc3b074520 fixes: coderabbit 2025-12-03 14:04:58 +05:30
naman-bruno
c7be4775b3 fixes: comments 2025-12-03 13:36:02 +05:30
naman-bruno
72d5411df8 fixes 2025-12-03 00:48:19 +05:30
naman-bruno
d167be658f fix: close tests 2025-12-03 00:25:55 +05:30
naman-bruno
08c183b4ec fix: tests 2025-12-03 00:06:40 +05:30
naman-bruno
c8d13f16c3 fixes 2025-12-02 14:42:00 +05:30
naman-bruno
399201bbc9 fixes 2025-12-02 14:41:51 +05:30
naman-bruno
93eae99302 init: workspaces 2025-12-02 14:41:39 +05:30
304 changed files with 4586 additions and 13320 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. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
- 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,9 +18,7 @@ module.exports = runESMImports().then(() => defineConfig([
'**/dist/**/*',
'**/*.bru',
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
'packages/bruno-app/public/static/**/*',
'packages/bruno-app/.next/**/*',
'packages/bruno-electron/web/**/*'
'packages/bruno-app/public/static/**/*'
]
},
{

1977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,8 +45,6 @@
"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",
@@ -85,7 +83,6 @@
"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

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

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

@@ -1,138 +0,0 @@
/**
* 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

@@ -1,51 +0,0 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { IconDeviceFloppy } from '@tabler/icons';
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
const FileEditor = ({ apiSpec }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [content, setContent] = useState(apiSpec?.raw);
const onEdit = (value) => {
setContent(value);
};
const onSave = () => {
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
};
const hasChanges = Boolean(content != apiSpec?.raw);
const editorMode = 'yaml';
return (
<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

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

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

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

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

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

@@ -1,252 +0,0 @@
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" data-testid="add-client-cert">
<button type="submit" className="submit btn btn-sm btn-secondary">
Add
</button>
<div className="h-4 border-l border-gray-600"></div>

View File

@@ -3,23 +3,15 @@ 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);
};
@@ -47,24 +39,9 @@ const Info = ({ collection }) => {
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
</div>
<div className="ml-4">
<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 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>
</div>
</div>

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -4,23 +4,18 @@ 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,32 +32,17 @@ 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 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 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 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) {
@@ -83,7 +68,11 @@ 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

@@ -1,8 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
display: inline-block;
`;
export default Wrapper;

View File

@@ -1,172 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { generateUniqueRequestName } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = collections?.find((c) => c.uid === collectionUid);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
if (!collection) {
return null;
}
const handleCreateHttpRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: '',
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGraphQLRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: '',
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: itemUid,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateWebSocketRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGrpcRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
return (
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateHttpRequest();
}}
>
<span className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</span>
HTTP
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGraphQLRequest();
}}
>
<span className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</span>
GraphQL
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateWebSocketRequest();
}}
>
<span className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</span>
WebSocket
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGrpcRequest();
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
gRPC
</div>
</Dropdown>
);
};
export default CreateUntitledRequest;

View File

@@ -168,7 +168,7 @@ const StyledWrapper = styled.div`
position: sticky;
top: 0;
z-index: 10;
td {
padding: 8px 12px;
font-weight: 500;
@@ -256,8 +256,10 @@ const StyledWrapper = styled.div`
}
.response-body-container {
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.console.headerBg};
height: 400px;
display: flex;
flex-direction: column;
@@ -265,11 +267,13 @@ const StyledWrapper = styled.div`
.w-full.h-full.relative.flex {
height: 100% !important;
width: 100% !important;
background: ${(props) => props.theme.console.headerBg} !important;
display: flex !important;
flex-direction: column !important;
}
div[role="tablist"] {
background: ${(props) => props.theme.console.dropdownHeaderBg};
padding: 8px 12px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
display: flex !important;
@@ -278,17 +282,28 @@ const StyledWrapper = styled.div`
align-items: center !important;
min-height: 40px !important;
flex-shrink: 0 !important;
> div {
color: ${(props) => props.theme.console.buttonColor};
font-size: ${(props) => props.theme.font.size.sm} !important;
padding: 6px 12px !important;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid ${(props) => props.theme.console.border};
background: ${(props) => props.theme.console.contentBg};
white-space: nowrap !important;
min-width: auto !important;
height: auto !important;
line-height: 1.2 !important;
font-weight: 500 !important;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
border-color: ${(props) => props.theme.console.buttonHoverBg};
}
&.active {
background: ${(props) => props.theme.console.checkboxColor};
color: white;

View File

@@ -7,7 +7,7 @@ import {
IconNetwork
} from '@tabler/icons';
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import QueryResult from 'components/ResponsePane/QueryResult';
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common/index';
@@ -116,7 +116,7 @@ const ResponseTab = ({ response, request, collection }) => {
<h4>Response Body</h4>
<div className="response-body-container">
{response?.data || response?.dataBuffer ? (
<QueryResponse
<QueryResult
item={{ uid: uuid() }}
collection={collection}
data={response.data}

View File

@@ -25,16 +25,6 @@ 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;
@@ -69,10 +59,6 @@ const Wrapper = styled.div`
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
@@ -84,31 +70,10 @@ 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,55 +2,46 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.25rem 0.3rem 0.25rem 0.5rem;
border-radius: 0.9375rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
user-select: none;
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
background-color: transparent;
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
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: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
align-self: center;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
color: ${(props) => props.theme.dropdown.selectedColor};
}
.env-text {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: ${(props) => props.theme.font.size.base};
display: block;
}
.env-separator {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
color: #8c8c8c;
margin: 0 0.25rem;
opacity: 0.7;
}
.env-text-inactive {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.dropdown.color};
font-size: ${(props) => props.theme.font.size.base};
opacity: 0.7;
}
&.no-environments {
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};
}
background-color: ${(props) => props.theme.sidebar.badge.bg};
border: 1px solid transparent;
color: ${(props) => props.theme.dropdown.secondaryText};
}
}

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, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { updateEnvironmentSettingsModalVisibility } 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,6 +20,8 @@ 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);
@@ -27,8 +29,6 @@ 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,8 +79,9 @@ const EnvironmentSelector = ({ collection }) => {
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
} else {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}
dropdownTippyRef.current.hide();
};
@@ -107,8 +108,9 @@ 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.
@@ -162,7 +164,7 @@ const EnvironmentSelector = ({ collection }) => {
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
);
return (
@@ -174,7 +176,7 @@ const EnvironmentSelector = ({ collection }) => {
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
});
@@ -218,7 +220,7 @@ const EnvironmentSelector = ({ collection }) => {
</div>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{isGlobalEnvironmentSettingsModalOpen && (
{showGlobalSettings && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
@@ -227,15 +229,13 @@ const EnvironmentSelector = ({ collection }) => {
/>
)}
{isEnvironmentSettingsModalOpen && (
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}}
/>
)}
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
setShowGlobalSettings(true);
}}
/>
)}
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
}}
/>
)}
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
}}
/>
)}

View File

@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -22,7 +22,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -8,19 +8,13 @@ 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,8 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = folderRoot?.request?.vars?.req || [];
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';

View File

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

@@ -88,9 +88,7 @@ const Modal = ({
return closeModal({ type: 'esc' });
}
case ENTER_KEY_CODE: {
// Skip if a submit button is focused - let native button click handle it to avoid double-fire
const isSubmitButton = event.target?.type === 'submit';
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm && !isSubmitButton) {
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
return handleConfirm();
}
}

View File

@@ -0,0 +1,30 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import find from 'lodash/find';
import get from 'lodash/get';
import classnames from 'classnames';
@@ -15,86 +15,54 @@ import Tests from 'components/RequestPane/Tests';
import { useTheme } from 'providers/Theme';
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'query', label: 'Query' },
{ key: 'variables', label: 'Variables' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const query = item.draft
? get(item, 'draft.request.body.graphql.query', '')
: get(item, 'request.body.graphql.query', '');
const variables = item.draft
? get(item, 'draft.request.body.graphql.variables')
: get(item, 'request.body.graphql.variables');
const { displayedTheme } = useTheme();
const [schema, setSchema] = useState(null);
const schemaActionsRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const preferences = useSelector((state) => state.app.preferences);
useEffect(() => {
onSchemaLoad(schema);
}, [schema, onSchemaLoad]);
}, [schema]);
const onQueryChange = useCallback(
(value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
},
[dispatch, item.uid, collection.uid]
);
const onQueryChange = (value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRun = useCallback(
() => dispatch(sendRequest(item, collection.uid)),
[dispatch, item, collection.uid]
);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const onSave = useCallback(
() => dispatch(saveRequest(item.uid, collection.uid)),
[dispatch, item.uid, collection.uid]
);
const selectTab = useCallback(
(tabKey) => {
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
},
[dispatch, item.uid]
);
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
case 'query':
const getTabPanel = (tab) => {
switch (tab) {
case 'query': {
return (
<QueryEditor
collection={collection}
@@ -109,55 +77,94 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
fontSize={get(preferences, 'font.codeFontSize')}
/>
);
case 'variables':
}
case 'variables': {
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
case 'headers':
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
case 'auth':
}
case 'auth': {
return <Auth item={item} collection={collection} />;
case 'vars':
}
case 'vars': {
return <Vars item={item} collection={collection} />;
case 'assert':
}
case 'assert': {
return <Assertions item={item} collection={collection} />;
case 'script':
}
case 'script': {
return <Script item={item} collection={collection} />;
case 'tests':
}
case 'tests': {
return <Tests item={item} collection={collection} />;
case 'docs':
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
case 'settings':
}
case 'settings': {
return <Settings item={item} collection={collection} />;
default:
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
};
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const rightContent = (
<div ref={schemaActionsRef}>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
);
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={schemaActionsRef}
/>
<section className={classnames('flex w-full flex-1', { 'mt-5': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('query')} role="tab" onClick={() => selectTab('query')}>
Query
</div>
<div className={getTabClassname('variables')} role="tab" onClick={() => selectTab('variables')}>
Variables
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</div>
</StyledWrapper>
);
};

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
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: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -1,25 +1,14 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.tabs {
div.more-tabs {
color: var(--color-tab-inactive) !important;
border-bottom: solid 2px transparent;
}
div.tabs {
div.tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
flex-shrink: 0;
&:focus,
&:active,
@@ -31,21 +20,12 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text};
}
sup {
display: inline-flex;
align-items: center;
line-height: 1;
vertical-align: baseline;
margin-left: 0;
color: ${(props) => props.theme.text}
}
}
}

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import React from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { find, get } from 'lodash';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryParams from 'components/RequestPane/QueryParams';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
@@ -12,144 +11,175 @@ import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import Settings from 'components/RequestPane/Settings';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
const TAB_CONFIG = [
{ key: 'params', label: 'Params' },
{ key: 'body', label: 'Body' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
{ key: 'script', label: 'Script' },
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'settings', label: 'Settings' }
];
const TAB_PANELS = {
params: QueryParams,
body: RequestBody,
headers: RequestHeaders,
auth: Auth,
vars: Vars,
assert: Assertions,
script: Script,
tests: Tests,
docs: Documentation,
settings: Settings
};
import { useEffect } from 'react';
import StatusDot from 'components/StatusDot';
import Settings from 'components/RequestPane/Settings';
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const bodyModeRef = useRef(null);
const initialAutoSelectDone = useRef(false);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'params': {
return <QueryParams item={item} collection={collection} />;
}
case 'body': {
return <RequestBody item={item} collection={collection} />;
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
case 'assert': {
return <Assertions item={item} collection={collection} />;
}
case 'script': {
return <Script item={item} collection={collection} />;
}
case 'tests': {
return <Tests item={item} collection={collection} />;
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
case 'settings': {
return <Settings item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const getProperty = useCallback(
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
[item.draft, item]
);
const params = getProperty('request.params');
const body = getProperty('request.body');
const headers = getProperty('request.headers');
const script = getProperty('request.script');
const assertions = getProperty('request.assertions');
const tests = getProperty('request.tests');
const docs = getProperty('request.docs');
const requestVars = getProperty('request.vars.req');
const responseVars = getProperty('request.vars.res');
const auth = getProperty('request.auth');
const tags = getProperty('tags');
const activeCounts = useMemo(() => ({
params: params.filter((p) => p.enabled).length,
headers: headers.filter((h) => h.enabled).length,
assertions: assertions.filter((a) => a.enabled).length,
vars: requestVars.filter((r) => r.enabled).length + responseVars.filter((r) => r.enabled).length
}), [params, headers, assertions, requestVars, responseVars]);
const selectTab = useCallback(
(tabKey) => {
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: tabKey }));
},
[dispatch, item.uid]
);
const indicators = useMemo(() => {
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
const hasTestError = item.testScriptErrorMessage;
return {
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
body: body.mode !== 'none' ? <StatusDot /> : null,
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
auth: auth.mode !== 'none' ? <StatusDot /> : null,
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
docs: docs?.length > 0 ? <StatusDot /> : null,
settings: tags?.length > 0 ? <StatusDot /> : null
};
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
[indicators]
);
const tabPanel = useMemo(() => {
const Component = TAB_PANELS[requestPaneTab];
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
useEffect(() => {
if (!initialAutoSelectDone.current && activeCounts.params === 0 && body.mode !== 'none') {
selectTab('body');
}
initialAutoSelectDone.current = true;
}, [activeCounts.params, body.mode, selectTab]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const rightContent = requestPaneTab === 'body' ? (
<div ref={bodyModeRef}>
<RequestBodyMode item={item} collection={collection} />
</div>
) : null;
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
const getPropertyFromDraftOrRequest = (propertyKey) =>
item.draft ? get(item, `draft.${propertyKey}`, []) : get(item, propertyKey, []);
const params = getPropertyFromDraftOrRequest('request.params');
const body = getPropertyFromDraftOrRequest('request.body');
const headers = getPropertyFromDraftOrRequest('request.headers');
const script = getPropertyFromDraftOrRequest('request.script');
const assertions = getPropertyFromDraftOrRequest('request.assertions');
const tests = getPropertyFromDraftOrRequest('request.tests');
const docs = getPropertyFromDraftOrRequest('request.docs');
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
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;
useEffect(() => {
if (activeParamsLength === 0 && body.mode !== 'none') {
selectTab('body');
}
}, []);
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={bodyModeRef}
delayedTabs={['body']}
/>
<section className={classnames('flex w-full flex-1', { 'mt-3': !isMultipleContentTab })}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Params
{activeParamsLength > 0 && <sup className="ml-1 font-medium">{activeParamsLength}</sup>}
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body
{body.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
{activeVarsLength > 0 && <sup className="ml-1 font-medium">{activeVarsLength}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
{(script.req || script.res) && (
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage
? <StatusDot type="error" />
: <StatusDot />
)}
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
{activeAssertionsLength > 0 && <sup className="ml-1 font-medium">{activeAssertionsLength}</sup>}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
{tests && tests.length > 0 && (
item.testScriptErrorMessage
? <StatusDot type="error" />
: <StatusDot />
)}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
{tags && tags.length > 0 && <StatusDot />}
</div>
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
<RequestBodyMode item={item} collection={collection} />
</div>
) : null}
</div>
<section
className={classnames('flex w-full flex-1', {
'mt-5': !isMultipleContentTab
})}
>
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</div>
</StyledWrapper>
);
};

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};

View File

@@ -1,19 +1,8 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import {
requestUrlChanged,
updateRequestMethod,
setRequestHeaders,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,
updateRequestGraphqlVariables,
updateRequestAuthMode,
updateAuth
} from 'providers/ReduxStore/slices/collections';
import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import { getRequestFromCurlCommand } from 'utils/curl';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
@@ -92,289 +81,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleGraphqlPaste = useCallback((event) => {
if (item.type !== 'graphql-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
toast.error('Invalid cURL command');
return;
}
event.preventDefault();
try {
const request = getRequestFromCurlCommand(pastedData, 'graphql-request');
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
}));
// Update method
dispatch(updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
}));
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
}));
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
if (bodyMode === 'graphql') {
dispatch(updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
}));
let variables = request.body.graphql.variables;
try {
variables = JSON.parse(variables);
} catch (error) {
// Keep variables as-is if JSON parsing fails
}
dispatch(updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: variables
}));
}
toast.success('GraphQL query imported successfully');
}
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse GraphQL query');
}
}, [dispatch, item.uid, collection.uid]);
const handleHttpPaste = useCallback((event) => {
// Only enable curl paste detection for HTTP requests
if (item.type !== 'http-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
// Check if pasted data looks like a cURL command
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
// Not a curl command, allow normal paste behavior
return;
}
// Prevent the default paste behavior
event.preventDefault();
try {
// Parse the curl command
const request = getRequestFromCurlCommand(pastedData);
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(
requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
})
);
// Update method
if (request.method) {
dispatch(
updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(
setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
})
);
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
// Set body mode first
dispatch(
updateRequestBodyMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: bodyMode
})
);
// Set body content based on mode
if (bodyMode === 'json' && request.body.json) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.json
})
);
} else if (bodyMode === 'text' && request.body.text) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.text
})
);
} else if (bodyMode === 'xml' && request.body.xml) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.xml
})
);
} else if (bodyMode === 'graphql' && request.body.graphql) {
if (request.body.graphql.query) {
dispatch(
updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
})
);
}
if (request.body.graphql.variables) {
dispatch(
updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: request.body.graphql.variables
})
);
}
} else if (bodyMode === 'formUrlEncoded' && request.body.formUrlEncoded) {
// For formUrlEncoded, we need to set each param individually
// This is a limitation - we'd need to clear existing params first
// For now, we'll set the body mode and the user can manually adjust
// TODO: Implement proper formUrlEncoded param setting
} else if (bodyMode === 'multipartForm' && request.body.multipartForm) {
// For multipartForm, similar limitation
// TODO: Implement proper multipartForm param setting
}
}
// Update auth
if (request.auth) {
const authMode = request.auth.mode;
if (authMode) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: authMode
})
);
// Set auth content based on mode
if (request.auth.basic) {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.basic
})
);
} else if (request.auth.bearer) {
dispatch(
updateAuth({
mode: 'bearer',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.bearer
})
);
} else if (request.auth.digest) {
dispatch(
updateAuth({
mode: 'digest',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.digest
})
);
} else if (request.auth.ntlm) {
dispatch(
updateAuth({
mode: 'ntlm',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.ntlm
})
);
} else if (request.auth.awsv4) {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.awsv4
})
);
} else if (request.auth.apikey) {
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.apikey
})
);
}
}
}
toast.success('cURL command imported successfully');
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse cURL command');
}
},
[dispatch, item.uid, item.type, collection.uid]
);
const handleCancelRequest = (e) => {
e.preventDefault();
e.stopPropagation();
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -398,12 +110,10 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
@@ -417,7 +127,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
@@ -432,7 +142,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconDeviceFloppy
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
size={22}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
@@ -443,7 +153,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
size={22}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
@@ -451,7 +161,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
size={22}
data-testid="send-arrow-icon"
/>
)}

View File

@@ -2,7 +2,6 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
min-width: 125px;
.body-mode-selector {
background: transparent;

View File

@@ -1,178 +0,0 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
const RequestPaneTabs = ({
tabs,
activeTab,
onTabSelect,
rightContent,
rightContentRef,
delayedTabs = []
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
},
[onTabSelect]
);
const calculateTabVisibility = useCallback(() => {
const container = tabsContainerRef.current;
if (!container || !tabs.length) return;
const containerWidth = container.offsetWidth;
const rightContentWidth = rightContentRef?.current
? rightContentRef.current.offsetWidth + 20
: 0;
const availableWidth = containerWidth - rightContentWidth - DROPDOWN_WIDTH;
const visible = [];
const overflow = [];
let currentWidth = 0;
for (const tab of tabs) {
const tabElement = tabRefsMap.current[tab.key];
const tabWidth = tabElement ? tabElement.offsetWidth + 20 : 100;
if (currentWidth + tabWidth <= availableWidth && !overflow.length) {
visible.push(tab);
currentWidth += tabWidth;
} else {
overflow.push(tab);
}
}
if (!visible.some((t) => t.key === activeTab) && overflow.length) {
const activeTabIndex = overflow.findIndex((t) => t.key === activeTab);
if (activeTabIndex !== -1) {
const [activeTabItem] = overflow.splice(activeTabIndex, 1);
const lastVisible = visible.pop();
if (lastVisible) overflow.unshift(lastVisible);
visible.push(activeTabItem);
}
}
setVisibleTabs(visible);
setOverflowTabs(overflow);
}, [tabs, activeTab, rightContentRef]);
const renderTab = useCallback(
(tab, isInDropdown = false) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
className={classnames('dropdown-item', { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: isActive })}
role="tab"
onClick={() => handleTabSelect(tab.key)}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
);
},
[activeTab, handleTabSelect]
);
useEffect(() => {
const delay = delayedTabs.includes(activeTab) ? CALCULATION_DELAY_EXTENDED : CALCULATION_DELAY_DEFAULT;
const timeoutId = setTimeout(() => requestAnimationFrame(calculateTabVisibility), delay);
return () => clearTimeout(timeoutId);
}, [calculateTabVisibility, activeTab, delayedTabs]);
useEffect(() => {
let frameId = null;
const observer = new ResizeObserver(() => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(calculateTabVisibility);
});
if (tabsContainerRef.current) observer.observe(tabsContainerRef.current);
if (rightContentRef?.current) observer.observe(rightContentRef.current);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [calculateTabVisibility, rightContentRef]);
const hiddenStyle = useMemo(
() => ({ visibility: 'hidden', position: 'absolute', display: 'flex', pointerEvents: 'none' }),
[]
);
return (
<StyledWrapper ref={tabsContainerRef} className="flex items-center tabs" role="tablist">
<div style={hiddenStyle}>
{tabs.map((tab) => (
<div
key={tab.key}
className={classnames('tab select-none', tab.key, { active: tab.key === activeTab })}
ref={(el) => el && (tabRefsMap.current[tab.key] = el)}
>
{tab.label}
{tab.indicator}
</div>
))}
</div>
{visibleTabs.map((tab) => renderTab(tab))}
{overflowTabs.length > 0 && (
<Dropdown
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
>
<div style={{ minWidth: '150px' }}>{overflowTabs.map((tab) => renderTab(tab, true))}</div>
</Dropdown>
)}
{rightContent && (
<div className="flex flex-grow justify-end items-center">
{rightContent}
</div>
)}
</StyledWrapper>
);
};
export default RequestPaneTabs;

View File

@@ -2,6 +2,7 @@ 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');
@@ -10,13 +11,8 @@ 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: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.1rem;
height: 2.3rem;
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-3 pb-3 px-4">
<div className="pt-4 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 { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import { IconFiles, 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,28 +45,26 @@ 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}>
<IconBox size={18} strokeWidth={1.5} />
<IconFiles 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={16} strokeWidth={1.5} onClick={handleRun} />
<IconRun className="cursor-pointer" size={18} strokeWidth={1.5} onClick={handleRun} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<IconEye className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewVariables} />
<IconEye className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewVariables} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<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} />
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span>

View File

@@ -8,7 +8,8 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
import StyledWrapper from '../RequestTab/StyledWrapper';
import GradientCloseButton from '../RequestTab/GradientCloseButton';
import CloseTabIcon from '../RequestTab/CloseTabIcon';
import DraftTabIcon from '../RequestTab/DraftTabIcon';
const ExampleTab = ({ tab, collection }) => {
const dispatch = useDispatch();
@@ -58,7 +59,7 @@ const ExampleTab = ({ tab, collection }) => {
if (!item || !example) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-3"
className="flex items-center justify-between tab-container px-1"
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
@@ -74,7 +75,7 @@ const ExampleTab = ({ tab, collection }) => {
}
return (
<StyledWrapper className="flex items-center justify-between tab-container px-3">
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -115,13 +116,13 @@ const ExampleTab = ({ tab, collection }) => {
}
}}
>
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
<span className="tab-name" title={example.name}>
{example.name}
</span>
</div>
<GradientCloseButton
hasChanges={hasChanges}
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!hasChanges) {
return handleCloseClick(e);
@@ -131,7 +132,13 @@ const ExampleTab = ({ tab, collection }) => {
e.preventDefault();
setShowConfirmClose(true);
}}
/>
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
</StyledWrapper>
);
};

View File

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

@@ -1,21 +0,0 @@
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,5 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
@@ -7,49 +8,49 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
switch (type) {
case 'collection-settings': {
return (
<>
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Collection</span>
</>
<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>
);
}
case 'collection-overview': {
return (
<>
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</>
);
}
case 'security-settings': {
return (
<>
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Security</span>
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Security</span>
</>
);
}
case 'folder-settings': {
return (
<>
<IconFolder size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">{tabName || 'Folder'}</span>
</>
<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>
);
}
case 'variables': {
return (
<>
<IconVariable size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Variables</span>
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Variables</span>
</>
);
}
case 'collection-runner': {
return (
<>
<IconRun size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Runner</span>
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Runner</span>
</>
);
}
@@ -58,13 +59,10 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
return (
<>
<div
className="flex items-baseline tab-label"
onDoubleClick={handleDoubleClick}
>
{getTabInfo(type, tabName)}
<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>
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
</>
);
};

View File

@@ -1,32 +1,43 @@
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;
font-size: 0.8125rem;
}
// so that the name does not cutoff when italicized
padding-right: 2px;
.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};
}
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import React, { useCallback, useState, useRef, Fragment, useMemo } 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,17 +17,16 @@ 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 GradientCloseButton from './GradientCloseButton';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import ExampleTab from '../ExampleTab';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
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);
@@ -37,48 +36,6 @@ 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();
@@ -148,12 +105,11 @@ 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-2 ${tab.preview ? 'italic' : ''}`}
onMouseUp={handleMouseUp}
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? 'italic' : ''}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
<ConfirmCollectionClose
@@ -236,6 +192,21 @@ 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) {
@@ -257,36 +228,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
const isWS = item.type === 'ws-request';
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]);
const method = getMethodText(item);
return (
<StyledWrapper className="flex items-center justify-between tab-container px-2">
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
@@ -323,7 +268,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
className={`flex items-baseline tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
@@ -339,7 +284,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
<span className="ml-1 tab-name" title={item.name}>
{item.name}
</span>
<RequestTabMenu
@@ -352,19 +297,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
dispatch={dispatch}
/>
</div>
<GradientCloseButton
hasChanges={hasChanges}
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!hasChanges) {
isWS && closeWsConnection(item.uid);
return handleCloseClick(e);
}
};
e.stopPropagation();
e.preventDefault();
setShowConfirmClose(true);
}}
/>
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
</StyledWrapper>
);
};
@@ -398,7 +349,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
}
dispatch(closeTabs({ tabUids: [tabUid] }));
} catch (err) { }
} catch (err) {}
}
function handleRevertChanges(event) {
@@ -417,7 +368,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
collectionUid: collection.uid
}));
}
} catch (err) { }
} catch (err) {}
}
function handleCloseOtherTabs(event) {

View File

@@ -1,44 +1,13 @@
import styled from 'styled-components';
const Wrapper = styled.div`
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;
}
}
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
ul {
padding: 0 2px;
padding: 0;
margin: 0;
display: flex;
align-items: flex-end;
position: relative;
z-index: 1;
overflow: scroll;
&::-webkit-scrollbar {
display: none;
@@ -48,127 +17,57 @@ const Wrapper = styled.div`
li {
display: inline-flex;
max-width: 180px;
min-width: 80px;
list-style: none;
cursor: pointer;
font-size: 0.8125rem;
position: relative;
margin-right: 3px;
color: ${(props) => props.theme.requestTabs.color};
background: transparent;
max-width: 150px;
border: 1px solid transparent;
padding: 6px 0;
flex-shrink: 0;
transition: background-color 0.15s ease;
margin-bottom: 3px;
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;
color: ${(props) => props.theme.requestTabs.color};
background: ${(props) => props.theme.requestTabs.bg};
border-radius: 0;
.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.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;
background: ${(props) => props.theme.requestTabs.active.bg};
}
&::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};
&.active {
.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};
&:hover {
.close-icon-container .close-icon {
display: block;
}
}
&.short-tab {
width: 32px;
min-width: 32px;
max-width: 32px;
padding: 5px 0;
vertical-align: bottom;
width: 34px;
min-width: 34px;
max-width: 34px;
padding: 3px 0px;
display: inline-flex;
justify-content: center;
align-items: center;
color: ${(props) => props.theme.requestTabs.shortTab.color};
background-color: transparent;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
flex-shrink: 0;
background-color: ${(props) => props.theme.requestTabs.shortTab.bg};
position: relative;
top: -1px;
> div {
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;
padding: 3px 4px;
}
> div.home-icon-container {
@@ -182,23 +81,19 @@ const Wrapper = styled.div`
}
svg {
height: 20px;
width: 20px;
height: 22px;
}
&: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, useEffect, useCallback } from 'react';
import React, { useState, useRef } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -10,15 +10,11 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateUntitledRequest from 'components/CreateUntitledRequest';
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);
@@ -26,48 +22,10 @@ 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,
'has-overflow': tabOverflowStates[tab.uid]
'last-tab': tabs && tabs.length && index === tabs.length - 1
});
};
@@ -79,26 +37,37 @@ const RequestTabs = () => {
);
};
const createNewTab = () => setNewRequestModalOpen(true);
if (!activeTabUid) {
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 = () => {
scrollContainerRef.current?.scrollBy({
tabsRef.current.scrollBy({
left: -120,
behavior: 'smooth'
});
};
// todo: bring new tab to focus if its not in focus
// tabsRef.current.scrollLeft
const rightSlide = () => {
scrollContainerRef.current?.scrollBy({
tabsRef.current.scrollBy({
left: 120,
behavior: 'smooth'
});
@@ -118,7 +87,7 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
<CollectionToolBar collection={activeCollection} />
<div className="flex items-center pl-2">
<div className="flex items-center pl-4">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
@@ -134,40 +103,36 @@ const RequestTabs = () => {
</div>
</li> */}
</ul>
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
<DraggableTab
<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}
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}
key={tab.uid}
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</DraggableTab>
);
})
: null}
</ul>
</div>
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
/>
</DraggableTab>
);
})
: null}
</ul>
<ul role="tablist">
{showChevrons ? (
@@ -177,16 +142,19 @@ const RequestTabs = () => {
</div>
</li>
) : null}
<div className="flex items-center short-tab">
{activeCollection && (
<CreateUntitledRequest
collectionUid={activeCollection.uid}
itemUid={null}
placement="bottom-start"
/>
)}
</div>
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
<div className="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</div>
</li>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -15,7 +15,7 @@ const ClearTimeline = ({ collection, item }) => {
);
return (
<StyledWrapper className="flex items-center">
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} className="text-link hover:underline" title="Clear Timeline">
Clear Timeline
</button>

View File

@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -51,7 +51,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -25,7 +25,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -10,6 +10,7 @@ import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseSave from '../ResponseSave';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';

View File

@@ -1,16 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
.query-response-content {
border-top: 1px solid ${(props) => props.theme.console.border};
}
`;
export default StyledWrapper;

View File

@@ -1,60 +0,0 @@
import React, { useEffect, useState } from 'react';
import QueryResult from '../QueryResult';
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index';
import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index';
import StyledWrapper from './StyledWrapper';
import classnames from 'classnames';
const QueryResponse = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error
}) => {
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
setSelectedFormat(initialFormat);
setSelectedTab(initialTab);
}
}, [initialFormat, initialTab]);
return (
<StyledWrapper>
<div className="flex items-center justify-end p-2">
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
</div>
<div className={classnames('flex-1 query-response-content', selectedTab === 'editor' ? 'px-2 py-1' : '')}>
<QueryResult
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
disableRunEventListener={disableRunEventListener}
headers={headers}
error={error}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
/>
</div>
</StyledWrapper>
);
};
export default QueryResponse;

View File

@@ -1,78 +0,0 @@
import React, { useRef, useState, useEffect } from 'react';
import { isValidHtml } from 'utils/common/index';
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
const HtmlPreview = React.memo(({ data, baseUrl }) => {
const webviewContainerRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!webviewContainerRef.current) return;
const checkDragging = () => {
const hasDraggingParent = webviewContainerRef.current?.closest('.dragging');
setIsDragging(!!hasDraggingParent);
};
// Watch from a common ancestor where .dragging gets added
const watchTarget = webviewContainerRef.current.closest('.main-section')
|| document.body;
const mutationObserver = new MutationObserver(checkDragging);
mutationObserver.observe(watchTarget, {
attributes: true,
attributeFilter: ['class'],
subtree: true
});
// Check initial state
checkDragging();
return () => mutationObserver.disconnect();
}, []);
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
const htmlContent = data.includes('<head>')
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {};
return (
<div
ref={webviewContainerRef}
className="h-full bg-white webview-container"
style={dragStyles}
>
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(htmlContent)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
style={dragStyles}
/>
</div>
);
}
// For all other data types, render safely as formatted text
let displayContent = '';
if (data === null || data === undefined) {
displayContent = String(data);
} else if (typeof data === 'object') {
displayContent = JSON.stringify(data, null);
} else if (typeof data === 'string') {
displayContent = data;
} else {
displayContent = String(data);
}
return (
<pre
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
>
{displayContent}
</pre>
);
});
export default HtmlPreview;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import ErrorAlert from 'ui/ErrorAlert/index';
const JsonPreview = ({ data, displayedTheme }) => {
// Helper function to validate and parse JSON data
const validateJsonData = (data) => {
// If data is already an object or array, use it directly
if (typeof data === 'object' && data !== null) {
return { data, error: null };
}
// If data is a string, try to parse it
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return { data: parsed, error: null };
} catch (e) {
return { data: null, error: `Invalid JSON format: ${e.message}` };
}
}
// For other types, return error
return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' };
};
// Validate and parse JSON data
const jsonData = validateJsonData(data);
// Show error if parsing failed
if (jsonData.error) {
return <ErrorAlert title="Cannot preview as JSON" message={jsonData.error} />;
}
// Validate that data can be rendered as JSON tree
if (jsonData.data === null || jsonData.data === undefined) {
return <ErrorAlert title="Cannot preview as JSON" message="Data is null or undefined. Expected a valid JSON object or array." />;
}
if (typeof jsonData.data !== 'object') {
return <ErrorAlert title="Cannot preview as JSON" message="Data cannot be rendered as a JSON tree. Expected a JSON object or array." />;
}
return (
<ReactJson
src={jsonData.data}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
collapsed={1}
displayDataTypes={false}
displayObjectSize={true}
enableClipboard={true}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
padding: '16px'
}}
/>
);
};
export default JsonPreview;

View File

@@ -1,25 +0,0 @@
import React, { memo, useMemo } from 'react';
const TextPreview = memo(({ data }) => {
const displayData = useMemo(() => {
if (data === null || data === undefined) {
return String(data);
}
if (typeof data === 'object') {
try {
return JSON.stringify(data);
} catch {
return String(data);
}
}
return String(data);
}, [data]);
return (
<div className="p-4 font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden w-full max-w-full h-full">
{displayData}
</div>
);
});
export default TextPreview;

View File

@@ -1,31 +0,0 @@
import React from 'react';
import { useEffect, useState } from 'react';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
export default VideoPreview;

View File

@@ -1,77 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 20px;
padding: 16px;
overflow: auto;
color: ${(props) => props.theme.text};
.xml-container {
color: ${(props) => props.theme.text};
}
.xml-node-name {
color: ${(props) => props.theme.codemirror.tokens.property};
font-weight: 500;
}
.xml-separator {
color: ${(props) => props.theme.codemirror.tokens.operator};
margin: 0 8px;
}
.xml-value {
color: ${(props) => props.theme.codemirror.tokens.string};
white-space: pre-wrap;
word-break: break-all;
}
.xml-empty-value {
color: ${(props) => props.theme.codemirror.tokens.comment};
}
.xml-count {
color: ${(props) => props.theme.codemirror.tokens.comment};
margin-left: 8px;
}
.xml-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
.xml-array-toggle-button {
margin-right: 8px;
cursor: pointer;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.codemirror.tokens.atom};
flex-shrink: 0;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: ${(props) => props.theme.console.buttonHoverBg};
}
}
`;
export default StyledWrapper;

View File

@@ -1,396 +0,0 @@
import ErrorAlert from 'ui/ErrorAlert/index';
import React, { useState, useMemo } from 'react';
import StyledWrapper from './StyledWrapper';
// The expected "data" prop must be an XML string.
export default function XmlPreview({ data, defaultExpanded = true }) {
// Parse XML string
const parsedData = useMemo(() => {
if (typeof data !== 'string') {
return { error: 'Invalid input. Expected an XML string.' };
}
const parsed = parseXMLString(data);
if (parsed === null) {
return { error: 'Failed to parse XML string. Invalid XML format.' };
}
return parsed;
}, [data]);
// Check for parsing error
if (parsedData && typeof parsedData === 'object' && parsedData.error) {
return (
<ErrorAlert title="Cannot preview as XML" message={parsedData.error} />
);
}
// Validate that data can be rendered as a tree
const isValidTreeData = (data) => {
if (data === null || data === undefined) return false;
if (typeof data === 'object' && !Array.isArray(data)) return true;
if (Array.isArray(data)) return true;
return false;
};
if (!isValidTreeData(parsedData)) {
return (
<ErrorAlert title="Cannot preview as XML" message="Data cannot be rendered as a tree. Expected a valid XML string." />
);
}
// If root is an object with a single key, unwrap it to show the actual root element
let rootNode = parsedData;
let rootNodeName = '';
if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) {
const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text');
if (keys.length === 1) {
rootNodeName = keys[0];
rootNode = parsedData[keys[0]];
} else if (keys.length === 0) {
// Empty object with no children
return (
<ErrorAlert title="Cannot preview as XML" message="Cannot render XML tree. Root object is empty." />
);
}
}
return (
<StyledWrapper>
<div className="xml-container">
<XmlNode
node={rootNode}
nodeName={rootNodeName}
isRoot={true}
isLast={true}
defaultExpanded={defaultExpanded}
/>
</div>
</StyledWrapper>
);
}
// Component for rendering array entries with expand/collapse functionality
const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
return (
<div style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-array-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">{arrayKey}</span>
<span className="xml-count">[{items.length}]</span>
</div>
{expanded && (
<div className="array-content">
{items.map((item, itemIdx) => (
<XmlNode
key={`${arrayKey}-${itemIdx}`}
node={item}
nodeName={`${itemIdx}`}
isLast={itemIdx === items.length - 1}
defaultExpanded={false}
depth={depth + 2}
/>
))}
</div>
)}
</div>
);
};
const XmlNode = ({
node,
nodeName = '',
isRoot = false,
isLast = true,
defaultExpanded = true,
depth = 0
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
let displayNodeName = nodeName;
if (Array.isArray(node)) {
// For repeated XML elements with same name (e.g. <item>...</item><item>...</item>)
return (
<>
{node.map((item, idx) => (
<XmlNode
key={idx}
node={item}
nodeName={displayNodeName}
isRoot={false}
isLast={idx === node.length - 1}
defaultExpanded={false}
depth={depth}
/>
))}
</>
);
}
const childEntries = getChildrenEntries(node);
const childCount = getChildCount(node);
const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0);
const toggle = (e) => {
e.stopPropagation();
setExpanded((v) => !v);
};
// For leaf nodes with text content or attributes with empty values
if (isLeaf && isTextNode(node)) {
const value = String(node);
return (
<div className="flex items-start mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
</>
)}
<span className="xml-value">{value}</span>
</div>
);
}
// For empty leaf nodes (attributes without values, etc)
if (isLeaf && !isTextNode(node)) {
// Check if this is an attribute-only node with _text
if (typeof node === 'object' && node !== null && '_text' in node) {
// This node has both attributes and text, handle in expandable section
// Fall through to expandable node rendering
} else {
return (
<div className="flex items-center mb-1" style={{ paddingLeft: `${depth * 20}px` }}>
{displayNodeName && (
<>
<span className="xml-node-name">{displayNodeName}</span>
<span className="xml-separator">:</span>
<span className="xml-empty-value">{'{}'}</span>
</>
)}
</div>
);
}
}
// For expandable nodes - show as tree structure
// If no node name at root level, render children directly
if (!displayNodeName && depth === 0) {
if (childEntries.length > 0) {
return (
<div>
{childEntries.map(([key, value], idx) => (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={defaultExpanded}
depth={0}
/>
))}
</div>
);
}
return null;
}
// If no display name at non-root level, use a fallback
if (!displayNodeName) {
displayNodeName = '(unnamed)';
}
// Determine if this node's value is an array
const hasArrayValue = Array.isArray(node);
const arrayLength = hasArrayValue ? node.length : 0;
return (
<div style={{ paddingLeft: `${depth * 20}px` }}>
<div className="flex items-center mb-1">
<button
onClick={toggle}
className="xml-toggle-button"
tabIndex={-1}
aria-expanded={expanded}
>
{expanded ? '▼' : '▶'}
</button>
<span className="xml-node-name">
{displayNodeName}
</span>
{childCount > 0 && (
<span className="xml-count">
{`{${childCount}}`}
</span>
)}
</div>
{expanded && childEntries.length > 0 && (
<div>
{childEntries.map(([key, value], idx) => {
// Check if this is an attribute (starts with _)
const isAttribute = key.startsWith('_');
// Handle attributes
if (isAttribute) {
const displayValue = value === '' ? 'value' : value;
return (
<div key={key + idx} className="flex items-start mb-1" style={{ paddingLeft: `${(depth + 1) * 20}px` }}>
<span className="xml-node-name">{key}</span>
<span className="xml-separator">:</span>
<span className={value === '' ? 'xml-empty-value' : 'xml-value'}>{displayValue}</span>
</div>
);
}
// Check if this child is an array
const isArrayChild = Array.isArray(value);
if (isArrayChild) {
return (
<XmlArrayNode
key={`${key}-${idx}`}
arrayKey={key}
items={value}
depth={depth}
defaultExpanded={true}
/>
);
}
return (
<XmlNode
key={key + idx}
node={value}
nodeName={key}
isLast={idx === childEntries.length - 1}
defaultExpanded={false}
depth={depth + 1}
/>
);
})}
</div>
)}
</div>
);
};
// Helper function to parse XML string to object
function parseXMLString(xmlString) {
if (typeof xmlString !== 'string') return null;
try {
const parser = new DOMParser();
// Parse as XML only
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
// Check for parsing errors
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
return null;
}
// Convert XML DOM to object
function xmlToObject(node) {
if (node.nodeType !== 1) return null; // Not an element node
const result = {};
// Get attributes - store them directly with underscore prefix
if (node.attributes && node.attributes.length > 0) {
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
result[`_${attr.name}`] = attr.value || '';
}
}
// Get child nodes
const childNodes = Array.from(node.childNodes);
const elementChildren = childNodes.filter((child) => child.nodeType === 1);
const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim());
// If only text children and no element children, return text content
if (elementChildren.length === 0 && textChildren.length > 0) {
const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim();
// If has attributes, store text as a special property
if (Object.keys(result).length > 0) {
result['_text'] = textContent;
return result;
}
return textContent || null;
}
// Process element children
if (elementChildren.length > 0) {
const childMap = {};
elementChildren.forEach((child) => {
const childName = child.nodeName; // Preserve original casing
const childValue = xmlToObject(child);
if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) {
if (childMap[childName]) {
// Multiple children with same name - convert to array
if (!Array.isArray(childMap[childName])) {
childMap[childName] = [childMap[childName]];
}
childMap[childName].push(childValue);
} else {
childMap[childName] = childValue;
}
}
});
// Merge children into result
Object.assign(result, childMap);
}
return Object.keys(result).length > 0 ? result : null;
}
const rootElement = xmlDoc.documentElement;
if (!rootElement) return null;
const parsed = xmlToObject(rootElement);
return parsed ? { [rootElement.nodeName]: parsed } : null;
} catch (error) {
return null;
}
}
function isTextNode(node) {
return typeof node === 'string' || typeof node === 'number' || node === null;
}
function getChildrenEntries(node) {
// Given an XML-like JS object, return an array of [key, value] for all properties
// This includes attributes (with _ prefix) and child elements
if (typeof node !== 'object' || node === null) return [];
return Object.entries(node);
}
function getChildCount(node) {
if (Array.isArray(node)) {
return node.length;
}
const children = getChildrenEntries(node);
return children.length;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
@@ -11,22 +11,44 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
import XmlPreview from './XmlPreview/index';
import TextPreview from './TextPreview';
import HtmlPreview from './HtmlPreview';
import VideoPreview from './VideoPreview';
import JsonPreview from './JsonPreview';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
const QueryResultPreview = ({
selectedTab,
previewTab,
allowedPreviewModes,
data,
dataBuffer,
formattedData,
item,
contentType,
collection,
codeMirrorMode,
previewMode,
mode,
disableRunEventListener,
displayedTheme
}) => {
@@ -41,6 +63,10 @@ const QueryResultPreview = ({
function onDocumentLoadSuccess({ numPages }) {
setNumPages(numPages);
}
// Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
return null;
}
const onRun = () => {
if (disableRunEventListener) {
@@ -61,31 +87,19 @@ const QueryResultPreview = ({
);
};
if (selectedTab === 'editor') {
return (
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={codeMirrorMode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
}
switch (previewMode) {
switch (previewTab?.mode) {
case 'preview-web': {
const baseUrl = item.requestSent?.url || '';
return <HtmlPreview data={data} baseUrl={baseUrl} />;
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
}
case 'preview-image': {
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} />;
return <img src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />;
}
case 'preview-pdf': {
return (
@@ -106,29 +120,24 @@ const QueryResultPreview = ({
case 'preview-video': {
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
}
case 'preview-json': {
return <JsonPreview data={data} displayedTheme={displayedTheme} />;
}
case 'preview-text': {
return <TextPreview data={data} />;
}
case 'preview-xml': {
return <XmlPreview data={data} />;
}
default:
case 'raw': {
return (
<div className="p-4 flex flex-col items-center justify-center h-full text-center">
<div className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Preview Available
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Sorry, no preview is available for this content type.
</div>
</div>
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={mode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
}
}
};

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { IconEye } from '@tabler/icons';
import ButtonDropdown from 'ui/ButtonDropdown';
import ToggleSwitch from 'components/ToggleSwitch';
import StyledWrapper from './StyledWrapper';
const QueryResultTypeSelector = ({
formatOptions,
formatValue,
onFormatChange,
onPreviewTabSelect,
selectedTab
}) => {
const header = (
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
<ToggleSwitch
isOn={selectedTab === 'preview'}
handleToggle={(e) => {
e.preventDefault();
// e.stopPropagation();
onPreviewTabSelect();
}}
size="2xs"
data-testid="preview-response-tab"
title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'}
/>
</div>
);
return (
<StyledWrapper>
<ButtonDropdown
label={formatValue}
options={formatOptions}
value={formatValue}
onChange={onFormatChange}
header={header}
className="h-[20px] text-[11px]"
data-testid="format-response-tab"
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
/>
</StyledWrapper>
);
};
export default QueryResultTypeSelector;

View File

@@ -1,8 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 100%;
grid-template-rows: 1.25rem 1fr;
/* This is a hack to force Codemirror to use all available space */
> div {

View File

@@ -1,34 +1,15 @@
import { debounce } from 'lodash';
import { useTheme } from 'providers/Theme/index';
import React, { useMemo, useState } from 'react';
import { formatResponse, getContentType } from 'utils/common';
import { getEncoding } from 'utils/common/index';
import { getDefaultResponseFormat } from 'utils/response';
import LargeResponseWarning from '../LargeResponseWarning';
import QueryResultFilter from './QueryResultFilter';
import React from 'react';
import classnames from 'classnames';
import { getContentType, formatResponse } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
import { detectContentTypeFromBuffer } from 'utils/response/index';
const PREVIEW_FORMAT_OPTIONS = [
{
// name: 'Structured',
options: [
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
]
},
{
// name: 'Raw',
options: [
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
]
}
];
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { getEncoding, uuid } from 'utils/common/index';
import LargeResponseWarning from '../LargeResponseWarning';
const formatErrorMessage = (error) => {
if (!error) return 'Something went wrong';
@@ -43,87 +24,9 @@ const formatErrorMessage = (error) => {
return error;
};
// Custom hook to determine the initial format and tab based on the data buffer and headers
export const useInitialResponseFormat = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
// Wait until both content types are available
if (detectedContentType === null || contentType === undefined) {
return { initialFormat: null, initialTab: null };
}
const initial = getDefaultResponseFormat(contentType);
return { initialFormat: initial.format, initialTab: initial.tab };
}, [dataBuffer, headers]);
};
// Custom hook to determine preview format options based on content type
export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
return useMemo(() => {
let buffer = null;
try {
buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null;
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const contentType = getContentType(headers);
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
const isByteFormatType = (contentType) => {
return byteFormatTypes.some((type) => contentType.includes(type));
};
const getContentTypeToCheck = () => {
if (detectedContentType) {
return detectedContentType;
}
return contentType;
};
const contentTypeToCheck = getContentTypeToCheck();
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
}
return PREVIEW_FORMAT_OPTIONS;
}, [dataBuffer, headers]);
};
const QueryResult = ({
item,
collection,
data,
dataBuffer,
disableRunEventListener,
headers,
error,
selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS
selectedTab // 'editor' or 'preview'
}) => {
let buffer = null;
try {
buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer
} catch (error) {
console.error('Error converting dataBuffer to Buffer:', error);
buffer = null;
}
const detectedContentType = detectContentTypeFromBuffer(buffer);
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
@@ -153,44 +56,65 @@ const QueryResult = ({
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, selectedFormat, filter);
return formatResponse(data, dataBuffer, mode, filter);
},
[data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse]
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
);
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
const previewMode = useMemo(() => {
// Derive preview mode based on selected format
if (selectedFormat === 'html') return 'preview-web';
if (selectedFormat === 'json') return 'preview-json';
if (selectedFormat === 'xml') return 'preview-xml';
if (selectedFormat === 'raw') return 'preview-text';
if (selectedFormat === 'javascript') return 'preview-web';
const allowedPreviewModes = useMemo(() => {
// Always show raw
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
// For base64/hex, check content type to determine binary preview type
if (selectedFormat === 'base64' || selectedFormat === 'hex') {
if (detectedContentType) {
if (detectedContentType.includes('image')) return 'preview-image';
if (detectedContentType.includes('pdf')) return 'preview-pdf';
if (detectedContentType.includes('audio')) return 'preview-audio';
if (detectedContentType.includes('video')) return 'preview-video';
}
// for all other content types, return preview-text
return 'preview-text';
if (!mode || !contentType) return allowedPreviewModes;
if (mode?.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
} else if (contentType.includes('pdf')) {
allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
} else if (contentType.includes('audio')) {
allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
} else if (contentType.includes('video')) {
allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
}
return 'preview-text';
}, [selectedFormat, detectedContentType]);
const codeMirrorMode = useMemo(() => {
return PREVIEW_FORMAT_OPTIONS
.flatMap((option) => option.options)
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
}, [selectedFormat]);
return allowedPreviewModes;
}, [mode, data, formattedData]);
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
// Ensure the active Tab is always allowed
useEffect(() => {
if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
setPreviewTab(allowedPreviewModes[0]);
}
}, [previewTab, allowedPreviewModes]);
const tabs = useMemo(() => {
if (allowedPreviewModes.length === 1) {
return null;
}
return allowedPreviewModes.map((previewMode) => (
<div
className={classnames(
'select-none capitalize',
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
)}
role="tab"
onClick={() => setPreviewTab(previewMode)}
key={previewMode?.uid}
>
{previewMode?.name}
</div>
));
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
@@ -198,6 +122,9 @@ const QueryResult = ({
className="w-full h-full relative flex"
queryFilterEnabled={queryFilterEnabled}
>
<div className="flex justify-end gap-2 text-xs" role="tablist">
{tabs}
</div>
{error ? (
<div>
{hasScriptError ? null : (
@@ -220,23 +147,21 @@ const QueryResult = ({
) : (
<div className="h-full flex flex-col">
<div className="flex-1 relative">
<div className="absolute top-0 left-0 h-full w-full" data-testid="response-preview-container">
<QueryResultPreview
selectedTab={selectedTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
previewMode={previewMode}
codeMirrorMode={codeMirrorMode}
collection={collection}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
</div>
<QueryResultPreview
previewTab={previewTab}
data={data}
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
mode={mode}
collection={collection}
allowedPreviewModes={allowedPreviewModes}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
/>
{queryFilterEnabled && (
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={codeMirrorMode} />
<QueryResultFilter filter={filter} onChange={debouncedResultFilterOnChange} mode={mode} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { IconDots } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseDownload from 'src/components/ResponsePane/ResponseDownload';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
const ResponseActions = ({ collection, item }) => {
const menuDropdownTippyRef = useRef();
@@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => {
<StyledWrapper className="ml-2 flex items-center">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
<ResponseDownload item={item} asDropdownItem onClose={handleClose} />
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
</Dropdown>
</StyledWrapper>
);

View File

@@ -3,13 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -24,51 +24,27 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
const ResponseBookmark = ({ item, collection, responseSize }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
const isDisabled = isResponseTooLarge || isStreamingResponse;
// Only show for HTTP requests
if (item.type !== 'http-request') {
return null;
}
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSaveClick(e);
}
};
const handleSaveClick = (e) => {
const handleSaveClick = () => {
if (!response || response.error) {
toast.error('No valid response to save as example');
e.preventDefault();
e.stopPropagation();
return;
}
if (isResponseTooLarge) {
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
e.preventDefault();
e.stopPropagation();
return;
}
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
@@ -140,28 +116,21 @@ const ResponseBookmark = ({ item, collection, responseSize, children }) => {
return (
<>
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
onClick={handleSaveClick}
title={
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
data-testid="response-bookmark-btn"
>
{children ?? (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconBookmark size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge || isStreamingResponse}
title={
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>
<IconBookmark size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
<CreateExampleModal
isOpen={showSaveResponseExampleModal}

View File

@@ -2,13 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
// Hook to get clear response function
export const useResponseClear = (item, collection) => {
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
const dispatch = useDispatch();
const clearResponse = () => {
if (onClose) onClose();
dispatch(
responseCleared({
itemUid: item.uid,
@@ -18,29 +18,21 @@ export const useResponseClear = (item, collection) => {
);
};
return { clearResponse };
};
const ResponseClear = ({ collection, item, children }) => {
const { clearResponse } = useResponseClear(item, collection);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearResponse();
}
};
if (asDropdownItem) {
return (
<div className="dropdown-item" onClick={clearResponse}>
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
Clear
</div>
);
}
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconEraser size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button onClick={clearResponse} title="Clear response">
<IconEraser size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
);
};
export default ResponseClear;

View File

@@ -2,13 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -3,8 +3,7 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
// Hook to get copy response function
export const useResponseCopy = (item) => {
const ResponseCopy = ({ item }) => {
const response = item.response || {};
const [copied, setCopied] = useState(false);
@@ -31,39 +30,16 @@ export const useResponseCopy = (item) => {
}
};
return { copyResponse, copied, hasData: !!response.data };
};
const ResponseCopy = ({ item, children }) => {
const { copyResponse, copied, hasData } = useResponseCopy(item);
const handleKeyDown = (e) => {
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
e.preventDefault();
copyResponse();
}
};
const handleClick = () => {
if (hasData) {
copyResponse();
}
};
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1" disabled={!hasData}>
{copied ? (
<IconCheck size={16} strokeWidth={2} />
) : (
<IconCopy size={16} strokeWidth={2} />
)}
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
{copied ? (
<IconCheck size={16} strokeWidth={1.5} />
) : (
<IconCopy size={16} strokeWidth={1.5} />
)}
</button>
</StyledWrapper>
);
};

View File

@@ -1,14 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
import classnames from 'classnames';
const ResponseDownload = ({ item, children }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer;
const saveResponseToFile = () => {
if (isDisabled) {
return;
}
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
saveResponseToFile();
}
};
return (
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onClick={saveResponseToFile}
onKeyDown={handleKeyDown}
title={!children ? 'Save response to file' : null}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
})}
>
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<IconDownload size={16} strokeWidth={2} />
</button>
</StyledWrapper>
)}
</div>
);
};
export default ResponseDownload;

View File

@@ -8,13 +8,7 @@ const Wrapper = styled.div`
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
color: ${(props) => props.theme.colors.text.muted};
}
`;

View File

@@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
export const IconDockToBottom = () => {
const IconDockToBottom = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
@@ -25,14 +25,14 @@ export const IconDockToBottom = () => {
);
};
export const IconDockToRight = () => {
const IconDockToRight = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="2"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
@@ -48,8 +48,7 @@ export const IconDockToRight = () => {
);
};
// Hook to get orientation and toggle function
export const useResponseLayoutToggle = () => {
const ResponseLayoutToggle = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
@@ -66,42 +65,19 @@ export const useResponseLayoutToggle = () => {
dispatch(savePreferences(updatedPreferences));
};
return { orientation, toggleOrientation };
};
const ResponseLayoutToggle = ({ children }) => {
const { orientation, toggleOrientation } = useResponseLayoutToggle();
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOrientation();
}
};
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
return (
<div
role="button"
tabIndex={0}
onClick={toggleOrientation}
title={title}
onKeyDown={handleKeyDown}
data-testid="response-layout-toggle-button"
>
{children ? children : (
<StyledWrapper className="flex items-center w-full">
<button className="p-1">
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
)}
</div>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={toggleOrientation}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
>
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
);
};

View File

@@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => {
describe('Initial Render', () => {
it('should render with horizontal orientation by default', () => {
renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
});
@@ -100,7 +100,7 @@ describe('ResponseLayoutToggle', () => {
}
};
renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
});
@@ -109,7 +109,7 @@ describe('ResponseLayoutToggle', () => {
describe('Interaction', () => {
it('should switch to vertical layout when clicked in horizontal mode', () => {
const { store } = renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
@@ -145,7 +145,7 @@ describe('ResponseLayoutToggle', () => {
}
};
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByRole('button');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');

View File

@@ -1,7 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
`;
export default StyledWrapper;

View File

@@ -1,188 +0,0 @@
import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react';
import { debounce } from 'lodash';
import styled from 'styled-components';
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ResponseDownload from '../ResponseDownload';
import ResponseBookmark from '../ResponseBookmark';
import ResponseClear from '../ResponseClear';
import ResponseLayoutToggle, { useResponseLayoutToggle, IconDockToBottom, IconDockToRight } from '../ResponseLayoutToggle';
import ResponseCopy from '../ResponseCopy/index';
import StyledWrapper from '../StyledWrapper';
const PADDING = 48;
const StyledMenuIcon = styled.button`
display: flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.5rem;
border: 1px solid ${(props) => props.theme.workspace.border};
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.text};
}
`;
const MenuIcon = forwardRef((props, ref) => (
<StyledMenuIcon
ref={ref}
title="More actions"
{...props}
>
<IconDots size={16} strokeWidth={1.5} />
</StyledMenuIcon>
));
MenuIcon.displayName = 'MenuIcon';
const ResponsePaneActions = ({ item, collection, responseSize }) => {
const { orientation } = useResponseLayoutToggle();
const [showMenu, setShowMenu] = useState(false);
const actionsRef = useRef(null);
const dropdownTippyRef = useRef();
const individualButtonsWidthRef = useRef(null);
const showMenuRef = useRef(showMenu);
const checkSpace = useCallback(() => {
const actionsContainer = actionsRef.current?.parentElement;
const rightSideContainer = actionsContainer?.closest('.right-side-container');
if (!actionsContainer || !rightSideContainer) return;
const currentActionsWidth = actionsContainer.offsetWidth || 0;
// Store individual buttons width when they're visible
if (!showMenuRef.current && currentActionsWidth > 0) {
individualButtonsWidthRef.current = currentActionsWidth;
}
// Calculate siblings total width
let siblingsTotalWidth = 0;
let sibling = actionsContainer.previousElementSibling;
while (sibling) {
siblingsTotalWidth += sibling.offsetWidth || 0;
sibling = sibling.previousElementSibling;
}
const actionsWidth = individualButtonsWidthRef.current || currentActionsWidth;
const requiredWidth = actionsWidth + siblingsTotalWidth + PADDING;
const shouldShowMenu = rightSideContainer.offsetWidth < requiredWidth;
if (showMenuRef.current !== shouldShowMenu) {
showMenuRef.current = shouldShowMenu;
setShowMenu(shouldShowMenu);
}
}, []);
const debouncedCheckSpace = useMemo(
() => debounce(checkSpace, 50),
[checkSpace]
);
useEffect(() => {
showMenuRef.current = showMenu;
}, [showMenu]);
useEffect(() => {
checkSpace();
const rightSideContainer = actionsRef.current?.closest('.right-side-container');
if (!rightSideContainer) return;
const resizeObserver = new ResizeObserver(debouncedCheckSpace);
resizeObserver.observe(rightSideContainer);
return () => {
resizeObserver.disconnect();
debouncedCheckSpace.cancel();
};
}, [item, debouncedCheckSpace]);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const closeDropdown = () => {
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
if (item.type !== 'http-request') {
return null;
}
return (
<StyledWrapper ref={actionsRef} className="flex items-center gap-2">
{showMenu ? (
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon data-testid="response-actions-menu" />} placement="bottom-end">
{/* Response Copy */}
<ResponseCopy item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={1.5} />
</span>
<span>Copy response</span>
</div>
</ResponseCopy>
{/* Response Save as Example */}
<ResponseBookmark item={item} collection={collection} responseSize={responseSize}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconBookmark size={16} strokeWidth={1.5} />
</span>
<span>Save response</span>
</div>
</ResponseBookmark>
{/* Response Download */}
<ResponseDownload item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconDownload size={16} strokeWidth={1.5} />
</span>
Download response
</div>
</ResponseDownload>
{/* Response Clear */}
<ResponseClear item={item} collection={collection}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconEraser size={16} strokeWidth={1.5} />
</span>
Clear response
</div>
</ResponseClear>
{/* Response Layout Toggle */}
<ResponseLayoutToggle>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
{orientation === 'horizontal' ? <IconDockToBottom /> : <IconDockToRight />}
</span>
<span>Change layout</span>
</div>
</ResponseLayoutToggle>
</Dropdown>
) : (
<div className="flex items-center gap-[2px]">
<ResponseCopy item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload item={item} />
<ResponseClear item={item} collection={collection} />
<ResponseLayoutToggle />
</div>
)}
</StyledWrapper>
);
};
export default ResponsePaneActions;

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
const ResponseSave = ({ item, asDropdownItem, onClose }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const saveResponseToFile = () => {
if (!response.dataBuffer) return;
if (onClose) onClose();
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
if (asDropdownItem) {
return (
<div
className="dropdown-item"
onClick={saveResponseToFile}
disabled={!response.dataBuffer}
style={!response.dataBuffer ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
<IconDownload size={16} strokeWidth={1.5} className="icon mr-2" />
Download
</div>
);
}
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={saveResponseToFile} disabled={!response.dataBuffer} title="Save response to file">
<IconDownload size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
);
};
export default ResponseSave;

View File

@@ -19,7 +19,7 @@ const ResponseSize = ({ size }) => {
}
return (
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-2">
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-4">
{sizeToDisplay}
</StyledWrapper>
);

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
font-weight: 500;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
text-align: center;
`;

View File

@@ -21,7 +21,7 @@ const ResponseStopWatch = ({ startMillis }) => {
let seconds = milliseconds / 1000;
let secondsFormatted = `${seconds.toFixed(1)}s`;
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
return <StyledWrapper className="ml-2" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
font-weight: 500;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;

View File

@@ -17,6 +17,6 @@ const ResponseTime = ({ duration }) => {
return null;
}
return <StyledWrapper className="ml-2">{durationToDisplay}</StyledWrapper>;
return <StyledWrapper className="ml-4">{durationToDisplay}</StyledWrapper>;
};
export default ResponseTime;

View File

@@ -2,8 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
white-space: nowrap;
font-weight: 500;
&.text-ok {
color: ${(props) => props.theme.requestTabPanel.responseOk};

View File

@@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper';
// Todo: text-error class is not getting pulled in for 500 errors
const StatusCode = ({ status, statusText, isStreaming }) => {
const getTabClassname = (status) => {
return classnames({
return classnames('ml-2', {
'text-ok': status >= 100 && status < 200,
'text-ok': status >= 200 && status < 300,
'text-error': status >= 300 && status < 400,

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
@@ -34,12 +33,6 @@ const StyledWrapper = styled.div`
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
.separator {
height: 16px;
border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border};
margin: 0 8px;
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import QueryResult from 'components/ResponsePane/QueryResult/index';
import { useState } from 'react';
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
@@ -14,7 +14,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResponse
<QueryResult
item={item}
collection={collection}
data={data}

View File

@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -25,7 +25,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

View File

@@ -16,11 +16,12 @@ import TestResultsLabel from './TestResultsLabel';
import ScriptError from './ScriptError';
import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponsePaneActions from './ResponsePaneActions';
import QueryResultTypeSelector from './QueryResult/QueryResultTypeSelector/index';
import { useInitialResponseFormat, useResponsePreviewFormatOptions } from './QueryResult/index';
import ResponseActions from 'src/components/ResponsePane/ResponseActions';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import ResponseCopy from 'src/components/ResponsePane/ResponseCopy';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
@@ -31,19 +32,6 @@ const ResponsePane = ({ item, collection }) => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
// Initialize format and tab only once when data loads
const { initialFormat, initialTab } = useInitialResponseFormat(item.response?.dataBuffer, item.response?.headers);
const previewFormatOptions = useResponsePreviewFormatOptions(item.response?.dataBuffer, item.response?.headers);
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
setSelectedFormat(initialFormat);
setSelectedTab(initialTab);
}
}, [initialFormat, initialTab]);
const requestTimeline = ([...(collection.timeline || [])]).filter((obj) => {
if (obj.itemUid === item.uid) return true;
@@ -98,8 +86,6 @@ const ResponsePane = ({ item, collection }) => {
headers={response.headers}
error={response.error}
key={item.filename}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
/>
);
}
@@ -171,7 +157,7 @@ const ResponsePane = ({ item, collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center px-4 tabs" role="tablist">
<div className="flex flex-wrap items-center px-4 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
@@ -191,50 +177,33 @@ const ResponsePane = ({ item, collection }) => {
/>
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center right-side-container">
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === 'response' ? (
<ResponseLayoutToggle />
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<>
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={(newFormat) => {
setSelectedFormat(newFormat);
}}
onPreviewTabSelect={() => {
setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor');
}}
selectedTab={selectedTab}
/>
<div className="separator" />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseCopy item={item} />
<ResponseActions item={item} collection={collection} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</>
) : null}
<div className="flex items-center response-pane-status">
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</div>
<div className="separator" />
<div className="flex items-center response-pane-actions">
{focusedTab?.responsePaneTab === 'timeline' ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<ResponsePaneActions item={item} collection={collection} responseSize={responseSize} />
) : null}
</div>
</div>
) : null}
</div>
<section
className="flex flex-col min-h-0 relative px-4 pt-3 auto overflow-auto"
className="flex flex-col min-h-0 relative px-4 auto overflow-auto"
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -20,7 +20,6 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}

Some files were not shown because too many files have changed in this diff Show More