feat: theme + ux overhaul (#6520)

* feat: theme + ux overhaul

* chore: moved @opencollection/types at root of monorepo

* chore: fixed review comments

* chore: addressed review comments

* chore: fix playwright tests

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
This commit is contained in:
Anoop M D
2025-12-27 13:52:20 +05:30
committed by GitHub
parent 84f572fa88
commit f40e4d2d79
127 changed files with 10380 additions and 1437 deletions

View File

@@ -52,6 +52,7 @@
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "^10.3.0",
"jsonschema": "^1.5.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",

View File

@@ -246,12 +246,6 @@ const Wrapper = styled.div`
color: white;
}
}
.dropdown-item-active {
font-weight: 400 !important;
background-color: ${(props) => props.theme.dropdown.selectedBg} !important;
color: ${(props) => props.theme.dropdown.selectedColor} !important;
}
`;
export default Wrapper;

View File

@@ -19,7 +19,7 @@ const StyledWrapper = styled.div`
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.brand};
}
.dropdown-icon {

View File

@@ -5,9 +5,9 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
.title {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-left: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-right: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-top: 1px solid ${(props) => props.theme.border.BORDER0};
border-left: 1px solid ${(props) => props.theme.border.BORDER0};
border-right: 1px solid ${(props) => props.theme.border.BORDER0};
border-top-left-radius: 3px;
border-top-right-radius: 3px;
@@ -15,8 +15,8 @@ const StyledWrapper = styled.div`
.table {
thead {
background-color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.bg};
color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.color};
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
}
}
}

View File

@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
}
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -4,7 +4,7 @@ const StyledWrapper = styled.div`
max-width: 800px;
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const Wrapper = styled.div`
min-width: 160px;
@@ -136,17 +137,21 @@ const Wrapper = styled.div`
/* Active/selected state - applied to the currently selected item */
&.dropdown-item-active {
color: ${({ theme }) => theme.colors.text.yellow};
background-color: ${({ theme }) => theme.dropdown.activeBg};
font-weight: 500;
color: ${({ theme }) => theme.dropdown.selectedColor} !important;
background-color: ${({ theme }) => rgba(theme.dropdown.selectedColor, 0.07)} !important;
.dropdown-icon {
color: ${({ theme }) => theme.colors.text.yellow};
color: ${({ theme }) => theme.dropdown.selectedColor} !important;
}
&:hover {
color: ${({ theme }) => theme.dropdown.selectedColor} !important;
background-color: ${({ theme }) => rgba(theme.dropdown.selectedColor, 0.07)} !important;
}
}
/* Combined state - when active item is also focused */
&.dropdown-item-active.dropdown-item-focused {
background-color: ${({ theme }) => theme.dropdown.activeHoverBg};
background-color: ${({ theme }) => rgba(theme.dropdown.selectedColor, 0.07)} !important;
}
/* Focus visible for accessibility */

View File

@@ -9,7 +9,7 @@ const StyledWrapper = styled.div`
.table-container {
overflow-y: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: ${(props) => props.theme.workspace.environments.indentBorder};
border: solid 1px ${(props) => props.theme.border.border0};
}
table {
@@ -31,8 +31,8 @@ const StyledWrapper = styled.div`
padding: 5px 10px !important;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
&:last-child {
@@ -58,8 +58,8 @@ const StyledWrapper = styled.div`
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
&:last-child {

View File

@@ -33,7 +33,7 @@ const EnvironmentListContent = ({
{environments.map((env) => (
<div
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}

View File

@@ -65,35 +65,6 @@ const Wrapper = styled.div`
overflow: hidden;
}
.tippy-box .tippy-content {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
.dropdown-item {
display: flex;
align-items: center;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.dropdown.primaryText};
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
background-color: ${(props) => props.theme.dropdown.selectedBg};
color: ${(props) => props.theme.dropdown.selectedColor};
}
&.no-environment {
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
.configure-button {
position: absolute;
bottom: 0;
@@ -109,7 +80,7 @@ const Wrapper = styled.div`
}
button {
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
display: flex;
align-items: center;
justify-content: center;
@@ -119,7 +90,7 @@ const Wrapper = styled.div`
}
.tab-button {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
font-size: ${(props) => props.theme.font.size.base};
.tab-content-wrapper {
@@ -170,7 +141,7 @@ const Wrapper = styled.div`
min-height: 12.5rem;
h3 {
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.5rem;
@@ -178,7 +149,7 @@ const Wrapper = styled.div`
}
p {
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
opacity: 0.75;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.5;
@@ -194,9 +165,9 @@ const Wrapper = styled.div`
}
.space-y-2 > button {
border: 0.0625rem solid ${(props) => props.theme.dropdown.primaryText};
border: 0.0625rem solid ${(props) => props.theme.text};
background: transparent;
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
padding: 0.5rem 1rem;
border-radius: 0.375rem;
width: 100%;
@@ -224,7 +195,7 @@ const Wrapper = styled.div`
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
line-height: 1.5;
text-align: center;
@@ -232,7 +203,7 @@ const Wrapper = styled.div`
svg {
margin: 0 auto 1rem auto;
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
opacity: 0.5;
}
}

View File

@@ -9,7 +9,7 @@ const Wrapper = styled.div`
.table-container {
overflow-y: auto;
border-radius: 8px;
border: ${(props) => props.theme.workspace.environments.indentBorder};
border: solid 1px ${(props) => props.theme.border.border0};
}
table {
@@ -46,8 +46,8 @@ const Wrapper = styled.div`
td {
padding: 5px 10px !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
@@ -64,8 +64,8 @@ const Wrapper = styled.div`
}
td {
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
@@ -75,26 +75,6 @@ const Wrapper = styled.div`
}
}
.btn-add-param {
font-size: 12px;
color: ${(props) => props.theme.textLink};
font-weight: 500;
padding: 7px 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: transparent;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
.tooltip-mod {
font-size: 11px !important;
max-width: 200px !important;

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
display: flex;
@@ -174,7 +175,7 @@ const StyledWrapper = styled.div`
}
&.active {
background: ${(props) => props.theme.workspace.environments.activeBg};
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
@@ -235,35 +236,34 @@ const StyledWrapper = styled.div`
gap: 2px;
margin-left: 4px;
}
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.colors.text.green};
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
&:hover {
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
color: ${(props) => props.theme.text};
}
&:hover {
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}

View File

@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 10%;
flex: 1;
color: ${(props) => props.theme.colors.text.muted};
@@ -32,22 +32,6 @@ const StyledWrapper = styled.div`
gap: 8px;
}
}
.shared-button {
padding: 5px 10px;
font-size: 12px;
border-radius: 5px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -5,18 +5,19 @@ import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
import Button from 'ui/Button';
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<button className="shared-button" onClick={() => setTab('create')}>
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</button>
<button className="shared-button" onClick={() => setTab('import')}>
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</button>
</Button>
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
}
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -9,7 +9,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -100,15 +100,14 @@ const StyledWrapper = styled.div`
.default-badge {
padding: 1px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.badge.bg};
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.background.surface1};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
}
.workspace-path {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const Wrapper = styled.div`
color: ${(props) => props.theme.text};
@@ -72,7 +73,6 @@ const Wrapper = styled.div`
background-color: ${(props) => props.theme.modal.title.bg};
font-size: ${(props) => props.theme.font.size.md};
padding: 0.5rem 1rem;
font-weight: 500;
border-top-left-radius: ${(props) => props.theme.border.radius.base};
border-top-right-radius: ${(props) => props.theme.border.radius.base};
@@ -98,7 +98,7 @@ const Wrapper = styled.div`
&:hover {
opacity: 1;
background-color: ${(props) => props.theme.modal.closeButton.hoverBg};
background-color: ${(props) => rgba(props.theme.modal.title.color, 0.1)};
}
}
}

View File

@@ -1,17 +1,9 @@
import React from 'react';
import Font from './Font/index';
import Theme from './Theme/index';
const Display = ({ close }) => {
return (
<div className="flex flex-col my-2 gap-10 w-full">
<div className="w-full flex flex-col gap-2">
<span>
Theme
</span>
<Theme close={close} />
</div>
<div className="h-[1px] bg-[#aaa5] w-full"></div>
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
</div>

View File

@@ -0,0 +1,97 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.theme-mode-selector {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.theme-mode-option {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.theme-variant-section {
margin-top: 20px;
}
.theme-variant-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 12px;
font-weight: 500;
}
.theme-variants {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.theme-variant-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 16px;
border: 2px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
cursor: pointer;
transition: all 0.15s ease;
min-width: 120px;
&:hover {
border-color: ${(props) => props.theme.input.focusBorder};
}
&.selected {
border-color: ${(props) => props.theme.accents.primary};
background: ${(props) => rgba(props.theme.accents.primary, 0.07)};
}
}
.theme-preview {
width: 60px;
height: 40px;
border-radius: ${(props) => props.theme.border.radius.sm};
margin-bottom: 8px;
display: flex;
overflow: hidden;
}
.theme-preview-sidebar {
width: 15px;
height: 100%;
}
.theme-preview-main {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
padding: 4px;
gap: 3px;
}
.theme-preview-line {
height: 4px;
border-radius: 2px;
width: 80%;
}
.theme-variant-name {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 24px 0;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
import { getLightThemes, getDarkThemes } from 'themes/index';
import StyledWrapper from './StyledWrapper';
const ThemePreview = ({ themeId, isDark }) => {
const bgColor = isDark ? '#1e1e1e' : '#ffffff';
const sidebarColor = isDark ? '#252526' : '#f8f8f8';
const lineColor = isDark ? '#3d3d3d' : '#e5e5e5';
return (
<div className="theme-preview" style={{ background: bgColor, border: `1px solid ${lineColor}` }}>
<div className="theme-preview-sidebar" style={{ background: sidebarColor }} />
<div className="theme-preview-main">
<div className="theme-preview-line" style={{ background: lineColor }} />
<div className="theme-preview-line" style={{ background: lineColor, width: '60%' }} />
<div className="theme-preview-line" style={{ background: lineColor, width: '70%' }} />
</div>
</div>
);
};
const ThemeVariantCard = ({ theme, isSelected, onClick }) => {
const isDark = theme.mode === 'dark';
return (
<div className={`theme-variant-card ${isSelected ? 'selected' : ''}`} onClick={onClick}>
<ThemePreview themeId={theme.id} isDark={isDark} />
<span className="theme-variant-name">{theme.name}</span>
</div>
);
};
const Themes = () => {
const {
storedTheme,
setStoredTheme,
themeVariantLight,
setThemeVariantLight,
themeVariantDark,
setThemeVariantDark
} = useTheme();
const lightThemes = getLightThemes();
const darkThemes = getDarkThemes();
const handleModeChange = (mode) => {
setStoredTheme(mode);
};
const renderThemeVariants = (themes, selectedVariant, onSelect, label) => (
<div className="theme-variant-section">
<div className="theme-variant-label">{label}</div>
<div className="theme-variants">
{themes.map((theme) => (
<ThemeVariantCard
key={theme.id}
theme={theme}
isSelected={selectedVariant === theme.id}
onClick={() => onSelect(theme.id)}
/>
))}
</div>
</div>
);
return (
<StyledWrapper>
<div className="flex flex-col gap-4 w-full">
<div>
<div className="section-header">Appearance</div>
<div className="theme-mode-selector">
<label className="theme-mode-option">
<input
type="radio"
name="theme-mode"
value="light"
checked={storedTheme === 'light'}
onChange={() => handleModeChange('light')}
/>
<span>Light</span>
</label>
<label className="theme-mode-option">
<input
type="radio"
name="theme-mode"
value="dark"
checked={storedTheme === 'dark'}
onChange={() => handleModeChange('dark')}
/>
<span>Dark</span>
</label>
<label className="theme-mode-option">
<input
type="radio"
name="theme-mode"
value="system"
checked={storedTheme === 'system'}
onChange={() => handleModeChange('system')}
/>
<span>System</span>
</label>
</div>
</div>
{storedTheme === 'light' && (
<>
{renderThemeVariants(lightThemes, themeVariantLight, setThemeVariantLight, 'Light Theme')}
</>
)}
{storedTheme === 'dark' && (
<>
{renderThemeVariants(darkThemes, themeVariantDark, setThemeVariantDark, 'Dark Theme')}
</>
)}
{storedTheme === 'system' && (
<>
{renderThemeVariants(lightThemes, themeVariantLight, setThemeVariantLight, 'Light Theme')}
<div className="section-divider" />
{renderThemeVariants(darkThemes, themeVariantDark, setThemeVariantDark, 'Dark Theme')}
</>
)}
</div>
</StyledWrapper>
);
};
export default Themes;

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import Support from './Support';
import General from './General';
import Themes from './Themes';
import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
@@ -26,6 +27,10 @@ const Preferences = ({ onClose }) => {
return <General close={onClose} />;
}
case 'themes': {
return <Themes close={onClose} />;
}
case 'proxy': {
return <Proxy close={onClose} />;
}
@@ -56,6 +61,9 @@ const Preferences = ({ onClose }) => {
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
General
</div>
<div className={getTabClassname('themes')} role="tab" onClick={() => setTab('themes')}>
Themes
</div>
<div className={getTabClassname('display')} role="tab" onClick={() => setTab('display')}>
Display
</div>

View File

@@ -4,55 +4,124 @@ const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
/* height: 100%; */
height: 100%;
position: relative;
.grpc-message-header {
.font-medium {
color: ${(props) => props.theme.text};
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
&.single {
height: 100%;
}
button {
&.multi {
overflow-y: auto;
padding-bottom: 48px;
}
}
.message-container {
display: flex;
flex-direction: column;
&.single {
height: 100%;
.editor-container {
height: calc(100% - 32px);
}
}
&:not(.single) {
min-height: 240px;
margin-bottom: 8px;
&.last {
margin-bottom: 0;
}
}
}
.message-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 4px 0px;
height: 32px;
flex-shrink: 0;
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
margin-right: auto;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
width: 28px;
height: 28px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
transition: all 0.15s ease;
&:hover {
transform: scale(1.1);
background-color: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.text};
}
&:active {
transform: scale(0.95);
&.disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
background-color: transparent;
color: ${(props) => props.theme.colors.text.muted};
}
}
&.delete:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
#grpc-messages-container {
/* height: 100%; */
position: relative;
.editor-container {
flex: 1;
min-height: 0;
}
.add-message-btn-container {
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
p {
color: ${(props) => props.theme.colors.text.muted};
font-size: 13px;
}
}
.add-message-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: 8px;
background: ${(props) => props.theme.bg || '#fff'};
z-index: 15;
border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
.add-message-btn {
width: 100%;
}
}
.CodeMirror {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: 8px;
background: ${(props) => props.theme.bg};
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -8,37 +8,85 @@ import { sendGrpcMessage, generateGrpcSampleMessage } from 'utils/network/index'
import useLocalStorage from 'hooks/useLocalStorage';
import CodeEditor from 'components/CodeEditor/index';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import toast from 'react-hot-toast';
import { getAbsoluteFilePath } from 'utils/common/path';
import { prettifyJsonString } from 'utils/common/index';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const MessageToolbar = ({
index,
canClientStream,
isConnectionActive,
onSend,
onRegenerateMessage,
onPrettify,
onDeleteMessage,
showDelete
}) => {
return (
<div className="message-toolbar">
<span className="message-label">Message {index + 1}</span>
<div className="toolbar-actions mr-2">
<ToolHint text="Format JSON" toolhintId={`prettify-msg-${index}`}>
<button onClick={onPrettify} className="toolbar-btn">
<IconWand size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<ToolHint text="Generate sample" toolhintId={`regenerate-msg-${index}`}>
<button onClick={onRegenerateMessage} className="toolbar-btn">
<IconRefresh size={16} strokeWidth={1.5} />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? 'Send message' : 'Connection not active'} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`toolbar-btn ${!isConnectionActive ? 'disabled' : ''}`}
data-testid={`grpc-send-message-${index}`}
>
<IconSend size={16} strokeWidth={1.5} />
</button>
</ToolHint>
)}
{showDelete && (
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="toolbar-btn delete">
<IconTrash size={16} strokeWidth={1.5} />
</button>
</ToolHint>
)}
</div>
</div>
);
};
const SingleGrpcMessage = ({ message, item, collection, index, methodType, handleRun, canClientSendMultipleMessages, isLast }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -53,60 +101,51 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
console.error('Error sending message:', error);
}
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(collection.pathname, protoPath);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
});
const result = await generateGrpcSampleMessage(methodPath, content, {
arraySize: 2,
methodMetadata
});
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
toast.success('Sample message generated successfully!');
toast.success('Sample message generated');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
@@ -118,9 +157,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -131,7 +168,6 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
const onPrettify = () => {
try {
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
@@ -147,101 +183,45 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
const isSingleMessage = !canClientSendMultipleMessages || body.grpc.length === 1;
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed
? <IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
: <IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />}
<span className="font-medium">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? 'Send gRPC message' : 'Connection not active'} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
data-testid={`grpc-send-message-${index}`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
<div className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
<MessageToolbar
index={index}
canClientStream={canClientStream}
isConnectionActive={isConnectionActive}
onSend={onSend}
onRegenerateMessage={onRegenerateMessage}
onPrettify={onPrettify}
onDeleteMessage={onDeleteMessage}
showDelete={index > 0}
/>
<div className="editor-container">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const dispatch = useDispatch();
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
const container = messagesContainerRef.current;
@@ -249,26 +229,12 @@ const GrpcBody = ({ item, collection, handleRun }) => {
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages((prev) => {
if (prev.includes(index)) {
return prev.filter((i) => i !== index);
} else {
return [...prev, index];
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
const currentMessages = Array.isArray(body.grpc) ? [...body.grpc] : [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -278,61 +244,60 @@ const GrpcBody = ({ item, collection, handleRun }) => {
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
<StyledWrapper>
<div className="empty-state">
<p>No gRPC messages available</p>
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
</StyledWrapper>
);
}
const messagesToShow = body.grpc.filter((_, index) => canClientSendMultipleMessages || index === 0);
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<StyledWrapper>
<div
ref={messagesContainerRef}
id="grpc-messages-container"
data-testid="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'}`}
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
{messagesToShow.map((message, index) => (
<SingleGrpcMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
isLast={index === messagesToShow.length - 1}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
data-testid="grpc-add-message-button"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
<div className="add-message-footer">
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
fullWidth
icon={<IconPlus size={14} strokeWidth={1.5} />}
data-testid="grpc-add-message-button"
>
Add Message
</Button>
</div>
)}
</StyledWrapper>

View File

@@ -49,7 +49,7 @@ const Wrapper = styled.div`
.infotip-text {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
background-color: ${(props) => props.theme.background.surface2};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
@@ -72,7 +72,7 @@ const Wrapper = styled.div`
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
border-color: ${(props) => props.theme.background.surface2} transparent transparent transparent;
}
.shortcut {

View File

@@ -348,7 +348,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<IconRefresh
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
size={20}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
@@ -367,7 +367,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
size={20}
/>
<span className="infotip-text text-xs">Generate grpcurl command</span>
</div>
@@ -383,7 +383,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
size={20}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
@@ -394,7 +394,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
@@ -403,7 +403,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={22}
size={20}
className="cursor-pointer"
/>
</div>
@@ -420,7 +420,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
handleRun(e);
}}
>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
)}
</div>

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
table {
width: 100%;

View File

@@ -49,7 +49,7 @@ const Wrapper = styled.div`
.infotiptext {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
background-color: ${(props) => props.theme.background.surface2};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
@@ -72,7 +72,7 @@ const Wrapper = styled.div`
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
border-color: ${(props) => props.theme.background.surface2} transparent transparent transparent;
}
.shortcut {

View File

@@ -9,7 +9,7 @@ const Wrapper = styled.div`
border-radius: 3px;
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.brand};
}
}

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
}
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
}
`;

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -17,13 +17,13 @@ const Wrapper = styled.div`
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.brand};
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
color: ${(props) => props.theme.colors.text.muted};
fill: ${(props) => props.theme.colors.text.muted};
}
`;

View File

@@ -0,0 +1,72 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
&.single {
height: 100%;
.editor-container {
height: calc(100% - 32px);
}
}
&:not(.single) {
min-height: 240px;
margin-bottom: 8px;
&.last {
margin-bottom: 0;
}
}
.message-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 4px 0px;
height: 32px;
flex-shrink: 0;
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
margin-right: auto;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
transition: all 0.15s ease;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.text};
}
&.delete:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
.editor-container {
flex: 1;
min-height: 0;
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import { IconChevronDown, IconChevronUp, IconTrash, IconWand } from '@tabler/icons';
import { IconTrash, IconWand } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor/index';
import ToolHint from 'components/ToolHint/index';
import { get } from 'lodash';
@@ -13,6 +13,7 @@ import { toastError } from 'utils/common/error';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
import WSRequestBodyMode from '../BodyMode/index';
import StyledWrapper from './StyledWrapper';
export const TYPE_BY_DECODER = {
base64: 'binary',
@@ -28,10 +29,9 @@ export const SingleWSMessage = ({
collection,
index,
methodType,
isCollapsed,
onToggleCollapse,
handleRun,
canClientSendMultipleMessages
canClientSendMultipleMessages,
isLast
}) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
@@ -88,9 +88,6 @@ export const SingleWSMessage = ({
}));
};
const getContainerHeight
= canClientSendMultipleMessages && body.ws.length > 1 ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
let codeType = messageFormat;
if (TYPE_BY_DECODER[type]) {
codeType = TYPE_BY_DECODER[type];
@@ -144,60 +141,44 @@ export const SingleWSMessage = ({
}
};
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
return (
<div
className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}
>
<div
className="ws-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ? (
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
) : (
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
)}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
<div className="message-toolbar">
<span className="message-label">Message {index + 1}</span>
<div className="toolbar-actions">
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
<ToolHint text="Prettify" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
<button onClick={onPrettify} className="toolbar-btn">
<IconWand size={16} strokeWidth={1.5} />
</button>
</ToolHint>
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="toolbar-btn delete">
<IconTrash size={16} strokeWidth={1.5} />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[codeType] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
)}
</div>
<div className="editor-container">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[codeType] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
</StyledWrapper>
);
};

View File

@@ -4,49 +4,45 @@ const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
height: 100%;
position: relative;
.ws-message-header {
.font-medium {
color: ${(props) => props.theme.text};
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
&.single {
height: 100%;
}
button {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&.multi {
overflow-y: auto;
padding-bottom: 48px;
}
}
.add-message-btn-container {
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
p {
color: ${(props) => props.theme.colors.text.muted};
font-size: 13px;
}
}
.add-message-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: 8px;
background: ${(props) => props.theme.bg || '#fff'};
z-index: 15;
border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
.add-message-btn {
width: 100%;
}
}
.CodeMirror {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: 8px;
background: ${(props) => props.theme.bg};
}
`;

View File

@@ -1,17 +1,14 @@
import { get } from 'lodash';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { IconPlus } from '@tabler/icons';
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ToolHint from 'components/ToolHint/index';
import React, { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { SingleWSMessage } from './SingleWSMessage/index';
const WSBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const dispatch = useDispatch();
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
@@ -26,16 +23,6 @@ const WSBody = ({ item, collection, handleRun }) => {
}
}, [body?.ws?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages((prev) => {
if (prev.includes(index)) {
return prev.filter((i) => i !== index);
} else {
return [...prev, index];
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
@@ -53,60 +40,58 @@ const WSBody = ({ item, collection, handleRun }) => {
if (!body?.ws || !Array.isArray(body.ws)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No WebSocket messages available</p>
<ToolHint text="Add the first message to your WebSocket request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
<StyledWrapper>
<div className="empty-state">
<p>No WebSocket messages available</p>
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
</StyledWrapper>
);
}
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<StyledWrapper>
<div
ref={messagesContainerRef}
id="ws-messages-container"
className={`flex-1 ${body.ws.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'
}`}
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
>
{body.ws
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleWSMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
{messagesToShow.map((message, index) => (
<SingleWSMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
isLast={index === messagesToShow.length - 1}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new WebSocket message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
<div className="add-message-footer">
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
fullWidth
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
)}
</StyledWrapper>

View File

@@ -62,7 +62,7 @@ const StyledWrapper = styled.div`
.infotip-text {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
background-color: ${(props) => props.theme.background.surface2};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
@@ -85,7 +85,7 @@ const StyledWrapper = styled.div`
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
border-color: ${(props) => props.theme.background.surface2} transparent transparent transparent;
}
.shortcut {
@@ -95,7 +95,7 @@ const StyledWrapper = styled.div`
.connection-controls {
.infotip {
&:hover {
background-color: ${(props) => props.theme.requestTabPanel.url.errorHoverBg};
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 6%, transparent);
}
}
}

View File

@@ -150,7 +150,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
<IconDeviceFloppy
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
@@ -164,7 +164,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
<IconPlugConnectedX
color={theme.colors.text.danger}
strokeWidth={1.5}
size={22}
size={20}
className="cursor-pointer"
/>
<span className="infotip-text text-xs">Close Connection</span>
@@ -181,7 +181,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
})}
color={theme.colors.text.green}
strokeWidth={1.5}
size={22}
size={20}
/>
<span className="infotip-text text-xs">Connect</span>
</div>
@@ -189,7 +189,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
)}
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
</div>
</div>

View File

@@ -7,8 +7,6 @@ import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
@@ -28,8 +26,7 @@ import toast from 'react-hot-toast';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow, dropdownContainerRef }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const { theme } = useTheme();
const tabNameRef = useRef(null);
const tabLabelRef = useRef(null);
const lastOverflowStateRef = useRef(null);

View File

@@ -26,7 +26,7 @@ const StyledWrapper = styled.div`
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.brand};
}
&.cursor-default {

View File

@@ -18,7 +18,7 @@ const StyledWrapper = styled.div`
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.brand};
}
}

View File

@@ -61,7 +61,7 @@ const StyledWrapper = styled.div`
cursor: pointer;
transition: background-color 0.15s ease;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
width: 100%;
box-sizing: border-box;

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -1,42 +1,39 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
border-left: 4px solid ${(props) => props.theme.colors.text.danger};
border-top: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid transparent;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
max-height: 200px;
min-height: 70px;
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 160px;
overflow-y: auto;
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')};
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 6%, transparent);
margin-bottom: 8px;
.close-button {
opacity: 0.7;
transition: opacity 0.2s;
opacity: 0.6;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
svg {
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.colors.text.muted};
}
}
.error-title {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
margin-bottom: 0.375rem;
margin-bottom: 4px;
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.25rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
color: ${(props) => props.theme.text};
}
`;

View File

@@ -6,7 +6,7 @@ const GrpcError = ({ error, onClose }) => {
if (!error) return null;
return (
<StyledWrapper className="mt-4 mb-2">
<StyledWrapper>
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">gRPC Server Error</div>

View File

@@ -3,94 +3,79 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
.CodeMirror {
height: 100%;
font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)};
font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')};
.empty-state {
color: ${(props) => props.theme.colors.text.muted};
padding: 1rem;
}
.accordion-header {
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
.responses-container {
height: 100%;
&.single {
height: 100%;
}
&.multi {
overflow-y: auto;
}
}
.messages-list {
display: flex;
flex-direction: column;
}
.message-item {
display: flex;
flex-direction: column;
&:not(.last) {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
}
.message-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
cursor: pointer;
user-select: none;
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
&.open {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
}
.error-header {
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')};
}
.error-text {
color: ${(props) => props.theme.colors.text.danger};
}
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.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;
.toggle-btn {
color: ${(props) => props.theme.text};
}
}
}
.stream-status {
display: inline-flex;
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
}
.latest-badge {
margin-left: 8px;
padding: 2px 6px;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
color: ${(props) => props.theme.colors.text.green};
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 10%, transparent);
border-radius: ${(props) => props.theme.border.radius.sm};
}
.toggle-btn {
display: flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
transition: color 0.15s ease;
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
.response-list {
max-height: 500px;
overflow-y: auto;
}
.response-message {
.message-content {
height: 240px;
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
background-color: var(--color-panel-background);
}
`;

View File

@@ -1,46 +1,31 @@
import React, { useState, useEffect } from 'react';
import Accordion from 'components/Accordion';
import CodeEditor from 'components/CodeEditor';
import { get } from 'lodash';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
import { formatISO9075 } from 'date-fns';
import GrpcError from '../GrpcError';
import { IconChevronDown, IconChevronUp } from '@tabler/icons';
const GrpcQueryResult = ({ item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [showErrorMessage, setShowErrorMessage] = useState(true);
const [expandedIndex, setExpandedIndex] = useState(0);
const response = item.response || {};
const responsesList = response?.responses || [];
// Reverse the responses list to show the most recent at the top
const reversedResponsesList = [...responsesList].reverse();
const hasError = response.isError;
const hasResponses = responsesList.length > 0;
const errorMessage = response.error;
// Reset error visibility when a new response is received
useEffect(() => {
if (hasError) {
setShowErrorMessage(true);
}
}, [response, hasError]);
// Format a timestamp to a human-readable format
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'Unknown time';
try {
const date = new Date(timestamp);
return formatISO9075(date);
} catch (e) {
return 'Invalid time';
}
};
// Format JSON for display
const formatJSON = (data) => {
try {
if (typeof data === 'string') {
@@ -55,7 +40,7 @@ const GrpcQueryResult = ({ item, collection }) => {
if (!hasResponses && !hasError) {
return (
<StyledWrapper className="w-full h-full relative flex flex-col">
<div className="text-gray-500 dark:text-gray-400 p-4">No messages received</div>
<div className="empty-state">No messages received</div>
</StyledWrapper>
);
}
@@ -64,9 +49,8 @@ const GrpcQueryResult = ({ item, collection }) => {
<StyledWrapper className="w-full h-full relative flex flex-col mt-2" data-testid="grpc-response-content">
{hasError && showErrorMessage && <GrpcError error={errorMessage} onClose={() => setShowErrorMessage(false)} />}
{hasResponses && (
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`} data-testid="grpc-responses-container">
<div className={`responses-container ${responsesList.length === 1 ? 'single' : 'multi'}`} data-testid="grpc-responses-container">
{responsesList.length === 1 ? (
// Single message - render directly without accordion
<div className="h-full" data-testid="grpc-single-response">
<CodeEditor
collection={collection}
@@ -79,43 +63,55 @@ const GrpcQueryResult = ({ item, collection }) => {
/>
</div>
) : (
// Multiple messages - use accordion
<Accordion defaultIndex={0} dataTestId="grpc-responses-accordion">
{reversedResponsesList.map((response, index) => {
// Calculate the original response number (for display purposes)
<div className="messages-list" data-testid="grpc-responses-list">
{reversedResponsesList.map((resp, index) => {
const originalIndex = responsesList.length - index - 1;
const isExpanded = expandedIndex === index;
return (
<Accordion.Item key={originalIndex} index={index} data-testid={`grpc-response-item-${originalIndex}`}>
<Accordion.Header index={index} style={{ padding: '8px 12px', minHeight: '40px' }}>
<div className="flex justify-between w-full">
<div className="font-medium">
Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''}
</div>
</div>
</Accordion.Header>
<Accordion.Content index={index} style={{ padding: '0px' }}>
<div className="h-60">
<div
key={originalIndex}
className={`message-item ${isExpanded ? 'expanded' : 'collapsed'} ${index === reversedResponsesList.length - 1 ? 'last' : ''}`}
data-testid={`grpc-response-item-${originalIndex}`}
>
<div
className="message-header"
onClick={() => setExpandedIndex(isExpanded ? -1 : index)}
>
<span className="message-label">
Response {originalIndex + 1}
{index === 0 && <span className="latest-badge">Latest</span>}
</span>
<button className="toggle-btn">
{isExpanded ? (
<IconChevronUp size={16} strokeWidth={1.5} />
) : (
<IconChevronDown size={16} strokeWidth={1.5} />
)}
</button>
</div>
{isExpanded && (
<div className="message-content">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(response)}
value={formatJSON(resp)}
mode="application/json"
readOnly={true}
/>
</div>
</Accordion.Content>
</Accordion.Item>
)}
</div>
);
})}
</Accordion>
</div>
)}
</div>
)}
{hasError && !hasResponses && !showErrorMessage && (
<div className="text-gray-500 dark:text-gray-400 p-4">
<div className="empty-state">
No messages received. A server error occurred but has been dismissed.
</div>
)}

View File

@@ -6,17 +6,24 @@ const StyledWrapper = styled.div`
border-collapse: collapse;
thead {
color: #777777;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
text-transform: uppercase;
}
td {
padding: 6px 10px;
padding: 8px 10px;
font-size: ${(props) => props.theme.font.size.sm};
&.key {
color: ${(props) => props.theme.colors.text.subtext1};
font-weight: 500;
}
&.value {
word-break: break-all;
color: ${(props) => props.theme.text};
}
}
@@ -25,6 +32,10 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.table.striped};
}
}
.empty-message {
color: ${(props) => props.theme.colors.text.muted};
}
}
`;

View File

@@ -24,7 +24,7 @@ const GrpcResponseHeaders = ({ metadata }) => {
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
<td colSpan="2" className="text-center py-4 empty-message">
No metadata received
</td>
</tr>

View File

@@ -6,17 +6,24 @@ const StyledWrapper = styled.div`
border-collapse: collapse;
thead {
color: #777777;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
text-transform: uppercase;
}
td {
padding: 6px 10px;
padding: 8px 10px;
font-size: ${(props) => props.theme.font.size.sm};
&.key {
color: ${(props) => props.theme.colors.text.subtext1};
font-weight: 500;
}
&.value {
word-break: break-all;
color: ${(props) => props.theme.text};
}
}
@@ -25,6 +32,10 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.table.striped};
}
}
.empty-message {
color: ${(props) => props.theme.colors.text.muted};
}
}
`;

View File

@@ -23,7 +23,7 @@ const ResponseTrailers = ({ trailers }) => {
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
<td colSpan="2" className="text-center py-4 empty-message">
No trailers received
</td>
</tr>

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
color: ${(props) => props.theme.background.surface2};
}
&.vertical-layout {

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
}
.button-dropdown-button {
color: ${(props) => props.theme.dropdown.primaryText};
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.workspace.border};
}

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
button {
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:hover {
@@ -18,7 +18,7 @@ const StyledWrapper = styled.div`
.cursor-pointer {
display: flex;
align-items: center;
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
&:hover {
color: var(--color-tab-active);

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

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

@@ -4,7 +4,7 @@ const StyledWrapper = styled.div`
padding-top: 20%;
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
color: ${(props) => props.theme.background.surface2};
}
`;

View File

@@ -25,7 +25,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
flex-shrink: 1;
overflow: hidden;

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -1,31 +1,59 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
overflow-y: auto;
overflow-y: auto;
.ws-message.new {
background-color: ${({ theme }) => theme.table.striped};
.empty-state {
padding: 1rem;
color: ${(props) => props.theme.colors.text.muted};
}
.ws-message:not(:last-child) {
border-bottom: 1px solid ${({ theme }) => theme.table.border};
}
.ws-message:not(:last-child).open {
border-bottom-width: 0px;
}
.ws-incoming {
.ws-message {
background: ${(props) => props.theme.bg};
border-color: ${(props) => props.theme.table.border};
&.new {
background-color: ${({ theme }) => theme.table.striped};
}
&:not(:last-child) {
border-bottom: 1px solid ${({ theme }) => theme.border.border1};
}
&:not(:last-child).open {
border-bottom-width: 0px;
}
.message-content {
color: ${(props) => props.theme.text};
}
.message-timestamp {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.chevron-icon {
color: ${(props) => props.theme.colors.text.muted};
}
}
.ws-outgoing {
background: ${(props) => props.theme.bg};
border-color: ${(props) => props.theme.table.border};
.ws-incoming .message-type-icon {
color: ${(props) => props.theme.colors.text.green};
}
.CodeMirror {
.ws-outgoing .message-type-icon {
color: ${(props) => props.theme.colors.text.yellow};
}
.ws-info .message-type-icon {
color: ${(props) => props.theme.colors.text.blue};
}
.ws-error .message-type-icon {
color: ${(props) => props.theme.colors.text.danger};
}
.CodeMirror {
border-radius: 0.25rem;
}
@@ -34,11 +62,12 @@ const StyledWrapper = styled.div`
}
div[role='tablist'] {
color: ${(props) => props.theme.colors.text.muted};
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default StyledWrapper;

View File

@@ -100,15 +100,17 @@ const WSMessageItem = ({ message, inFocus }) => {
}}
className={classnames('ws-message flex flex-col p-2', {
'ws-incoming': isIncoming,
'ws-outgoing': !isIncoming,
'ws-outgoing': isOutgoing,
'ws-info': isInfo,
'ws-error': isError,
'open': isOpen,
'new': isNew
})}
>
<div
className={classnames('flex items-center justify-between', {
'cursor-pointer': !isInfo,
'cursor-not-allowed': isInfo
'cursor-pointer': canOpenMessage,
'cursor-not-allowed': !canOpenMessage
})}
onClick={(e) => {
if (!canOpenMessage) return;
@@ -116,35 +118,26 @@ const WSMessageItem = ({ message, inFocus }) => {
}}
>
<div className="flex min-w-0 shrink">
<span
className={classnames('font-medium flex items-center gap-1',
{
'text-green-700': isIncoming,
'text-yellow-700': isOutgoing,
'text-blue-700': isInfo,
'text-red-700': isError,
'text-red-700': isError
})}
>
<span className="message-type-icon">
<TypeIcon type={message.type} />
</span>
<span className="ml-3 text-ellipsis max-w-full overflow-hidden text-nowrap">{parsedContent.content}</span>
<span className="ml-3 text-ellipsis max-w-full overflow-hidden text-nowrap message-content">{parsedContent.content}</span>
</div>
<div className="flex shrink-0 gap-2">
<div className="flex shrink-0 gap-2 items-center">
{message.timestamp && (
<span className="text-xs text-gray-400">{new Date(message.timestamp).toISOString()}</span>
<span className="message-timestamp">{new Date(message.timestamp).toISOString()}</span>
)}
{canOpenMessage
? (
<span className="text-gray-600">
<span className="chevron-icon">
{isOpen ? (
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
<IconChevronDown size={16} strokeWidth={1.5} />
) : (
<IconChevronRight size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
<IconChevronRight size={16} strokeWidth={1.5} />
)}
</span>
)
: <span class="w-4"></span>}
: <span className="w-4"></span>}
</div>
</div>
{isOpen && (
@@ -188,11 +181,11 @@ const WSMessageItem = ({ message, inFocus }) => {
const WSMessagesList = ({ order = -1, messages = [] }) => {
if (!messages.length) {
return <div className="p-4 text-gray-500">No messages yet.</div>;
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
}
const ordered = order === -1 ? messages : messages.slice().reverse();
return (
<StyledWrapper className="ws-messages-list mt-1 flex flex-col">
<StyledWrapper className="ws-messages-list mt-2 flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem key={msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;

View File

@@ -7,7 +7,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,

View File

@@ -88,7 +88,7 @@ const StyledWrapper = styled.div`
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&.is-selected {
background-color: ${(props) => props.theme.requestTabs.active.bg};
background-color: ${(props) => props.theme.background.surface2};
}
&.is-dragging {

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.tabs {
@@ -7,7 +8,7 @@ const StyledWrapper = styled.div`
border: none;
border-bottom: solid 2px transparent;
margin-right: ${(props) => props.theme.tabs.marginRight};
color: var(--color-tab-inactive);
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,
@@ -31,14 +32,24 @@ const StyledWrapper = styled.div`
display: flex;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 10px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background-color: ${(props) => props.theme.sidebar.bg};
border: 1px solid ${(props) => props.theme.border.border0};
background-color: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&.no-padding {
padding: 0px;
}
.note-warning {
color: ${(props) => props.theme.colors.text.warning};
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
}
&:hover {
background-color: ${(props) => props.theme.listItem.hoverBg};
background-color: ${(props) => props.theme.background.mantle};
border-color: ${(props) => props.theme.border.border2};
}
}
`;

View File

@@ -105,7 +105,7 @@ const ShareCollection = ({ onClose, collectionUid }) => {
</div>
<div
className={`flex !flex-col share-button ${
className={`flex !flex-col share-button no-padding ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
@@ -113,7 +113,7 @@ const ShareCollection = ({ onClose, collectionUid }) => {
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
{hasNonExportableRequestTypes.has && (
<div className="px-3 py-2 bg-yellow-50 w-full dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 text-xs border-b border-yellow-100 dark:border-yellow-800/20 flex items-center">
<div className="px-3 py-2 w-full flex items-center note-warning">
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
<span>
Note:

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
height: 50vh;
display: flex;
flex-direction: column;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
background-color: ${(props) => props.theme.background.base};
.code-generator {
display: flex;

View File

@@ -25,7 +25,7 @@ const StyledWrapper = styled.div`
display: inline-flex;
align-items: center;
padding: 6px 12px;
background-color: ${(props) => props.theme.requestTabs.active.bg};
background-color: ${(props) => props.theme.background.surface2};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-radius: 4px;
font-size: ${(props) => props.theme.font.size.sm};

View File

@@ -320,12 +320,12 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</div>
<div className="flex justify-end">
<span className="mr-2">
<Button type="button" size="sm" color="secondary" variant="ghost" onClick={onClose}>
<Button type="button" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
</span>
<span>
<Button type="submit" size="sm">
<Button type="submit">
Create
</Button>
</span>

View File

@@ -1,17 +1,18 @@
import styled from 'styled-components';
import { darken, rgba } from 'polished';
const Wrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.sidebar.badge.bg};
background-color: ${(props) => props.theme.background.surface1};
border-radius: 4px;
padding: 0.4rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.sidebar.badge.border || 'transparent'};
border: 1px solid ${(props) => props.theme.background.surface2};
}
.current-group:hover {
background-color: ${(props) => props.theme.sidebar.badge.hoverBg || props.theme.sidebar.badge.bg};
}
background-color: ${(props) => darken(0.03, props.theme.background.surface1)};
border-color: ${(props) => darken(0.03, props.theme.background.surface2)};
/* Fix dropdown positioning */
[data-tippy-root] {

View File

@@ -0,0 +1,81 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.theme-menu {
min-width: 160px;
padding: 4px 0;
background: ${(props) => props.theme.dropdown.bg};
border: 1px solid ${(props) => props.theme.dropdown.separator};
border-radius: ${(props) => props.theme.border.radius.md};
box-shadow: ${(props) => props.theme.dropdown.shadow};
}
.menu-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: ${(props) => props.theme.dropdown.mutedText};
letter-spacing: 0.5px;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
cursor: pointer;
color: ${(props) => props.theme.dropdown.color};
font-size: ${(props) => props.theme.font.size.sm};
&:hover {
background: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
color: ${(props) => props.theme.dropdown.selectedColor};
background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.07)};
}
&.has-submenu {
padding-right: 8px;
}
}
.menu-item-icon {
margin-right: 8px;
opacity: 0.7;
}
.menu-item-label {
flex: 1;
}
.check-icon {
color: ${(props) => props.theme.dropdown.selectedColor};
margin-left: 8px;
}
.chevron-icon {
opacity: 0.6;
margin-left: 8px;
}
.menu-divider {
height: 1px;
background: ${(props) => props.theme.dropdown.separator};
margin: 4px 0;
}
.submenu {
min-width: 180px;
padding: 4px 0;
background: ${(props) => props.theme.dropdown.bg};
border: 1px solid ${(props) => props.theme.dropdown.separator};
border-radius: ${(props) => props.theme.border.radius.md};
box-shadow: ${(props) => props.theme.dropdown.shadow};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,181 @@
import React, { useState } from 'react';
import Tippy from '@tippyjs/react';
import { IconChevronRight, IconCheck, IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import { useTheme } from 'providers/Theme';
import { getLightThemes, getDarkThemes } from 'themes/index';
import StyledWrapper from './StyledWrapper';
const ThemeDropdown = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [lightSubmenuOpen, setLightSubmenuOpen] = useState(false);
const [darkSubmenuOpen, setDarkSubmenuOpen] = useState(false);
const [tooltipEnabled, setTooltipEnabled] = useState(true);
const {
storedTheme,
setStoredTheme,
themeVariantLight,
themeVariantDark,
setThemeVariantLight,
setThemeVariantDark
} = useTheme();
const lightThemes = getLightThemes();
const darkThemes = getDarkThemes();
const handleModeSelect = (mode) => {
setStoredTheme(mode);
};
const handleThemeSelect = (themeId, isLight) => {
if (isLight) {
setThemeVariantLight(themeId);
} else {
setThemeVariantDark(themeId);
}
setIsOpen(false);
setLightSubmenuOpen(false);
setDarkSubmenuOpen(false);
};
const renderSubmenu = (themes, isLight, currentVariant) => (
<div className="submenu">
{themes.map((theme) => (
<div
key={theme.id}
className={`menu-item ${currentVariant === theme.id ? 'active' : ''}`}
onClick={() => handleThemeSelect(theme.id, isLight)}
>
<span className="menu-item-label">{theme.name}</span>
{currentVariant === theme.id && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
))}
</div>
);
const menuContent = (
<StyledWrapper>
<div className="theme-menu">
{/* Mode Section */}
<div className="menu-label">Mode</div>
<div
className={`menu-item ${storedTheme === 'light' ? 'active' : ''}`}
onClick={() => handleModeSelect('light')}
>
<IconSun size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">Light</span>
{storedTheme === 'light' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div
className={`menu-item ${storedTheme === 'dark' ? 'active' : ''}`}
onClick={() => handleModeSelect('dark')}
>
<IconMoon size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">Dark</span>
{storedTheme === 'dark' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div
className={`menu-item ${storedTheme === 'system' ? 'active' : ''}`}
onClick={() => handleModeSelect('system')}
>
<IconDeviceDesktop size={14} strokeWidth={1.5} className="menu-item-icon" />
<span className="menu-item-label">System</span>
{storedTheme === 'system' && (
<IconCheck size={14} strokeWidth={2} className="check-icon" />
)}
</div>
<div className="menu-divider" />
{/* Light Themes with Submenu */}
<Tippy
content={renderSubmenu(lightThemes, true, themeVariantLight)}
placement="right-start"
interactive={true}
arrow={false}
offset={[0, 2]}
animation={false}
visible={lightSubmenuOpen}
onClickOutside={() => setLightSubmenuOpen(false)}
appendTo="parent"
>
<div
className="menu-item has-submenu"
onMouseEnter={() => {
setLightSubmenuOpen(true);
setDarkSubmenuOpen(false);
}}
>
<span className="menu-item-label">Light Themes</span>
<IconChevronRight size={14} strokeWidth={2} className="chevron-icon" />
</div>
</Tippy>
{/* Dark Themes with Submenu */}
<Tippy
content={renderSubmenu(darkThemes, false, themeVariantDark)}
placement="right-start"
interactive={true}
arrow={false}
offset={[0, 2]}
animation={false}
visible={darkSubmenuOpen}
onClickOutside={() => setDarkSubmenuOpen(false)}
appendTo="parent"
>
<div
className="menu-item has-submenu"
onMouseEnter={() => {
setDarkSubmenuOpen(true);
setLightSubmenuOpen(false);
}}
>
<span className="menu-item-label">Dark Themes</span>
<IconChevronRight size={14} strokeWidth={2} className="chevron-icon" />
</div>
</Tippy>
</div>
</StyledWrapper>
);
const handleOpen = () => {
setTooltipEnabled(false);
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
setLightSubmenuOpen(false);
setDarkSubmenuOpen(false);
// Small delay before re-enabling tooltip to prevent flash
setTimeout(() => setTooltipEnabled(true), 100);
};
return (
<ToolHint text="Theme" toolhintId="ThemeDropdown" place="top" offset={10} hidden={!tooltipEnabled}>
<Tippy
content={menuContent}
placement="top-start"
interactive={true}
arrow={false}
animation={false}
visible={isOpen}
onClickOutside={handleClose}
appendTo="parent"
>
<div onClick={() => isOpen ? handleClose() : handleOpen()}>
{children}
</div>
</Tippy>
</ToolHint>
);
};
export default ThemeDropdown;

View File

@@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconSettings, IconCookie, IconTool, IconSearch } from '@tabler/icons';
import { IconSettings, IconCookie, IconTool, IconSearch, IconPalette } from '@tabler/icons';
import Mousetrap from 'mousetrap';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import ToolHint from 'components/ToolHint';
import Preferences from 'components/Preferences';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import Cookies from 'components/Cookies';
import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
import ThemeDropdown from './ThemeDropdown';
import { showPreferences } from 'providers/ReduxStore/slices/app';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { useApp } from 'providers/App';
@@ -81,6 +81,17 @@ const StatusBar = () => {
</button>
</ToolHint>
<ThemeDropdown>
<button
className="status-bar-button"
data-trigger="theme"
tabIndex={0}
aria-label="Change Theme"
>
<IconPalette size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</ThemeDropdown>
<ToolHint text="Notifications" toolhintId="Notifications" place="top" offset={10}>
<div className="status-bar-button">
<Notifications />

View File

@@ -1,77 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
display: grid;
overflow-y: hidden;
overflow-x: auto;
padding: 0 1.5px;
border-radius: 8px;
// for icon hover
position: inherit;
grid-template-columns: ${({ columns }) =>
columns?.[0]?.width
? columns.map((col) => `${col?.width}`).join(' ')
: columns.map((col) => `${100 / columns.length}%`).join(' ')};
}
table thead,
table tbody,
table tr {
display: contents;
border: none;
}
table th {
position: relative;
border: none;
border-bottom: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
background: ${(props) => props.theme.examples.table.thead.bg};
color: ${(props) => props.theme.examples.table.thead.color};
font-weight: 400;
tr td {
border: none;
color: ${(props) => props.theme.examples.table.thead.color};
background: ${(props) => props.theme.examples.table.thead.bg};
}
}
table tr td {
padding: 0.5rem;
text-align: left;
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
}
tr {
transition: transform 0.2s ease-in-out;
}
tr.dragging {
opacity: 0.5;
}
tr.hovered {
transform: translateY(10px); /* Adjust the value as needed for the animation effect */
}
table tr th {
padding: 0.5rem;
text-align: left;
border-top: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
font-weight: 400;
&:nth-child(1) {
border-left: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
}
&:last-child {
border-right: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
}
}
`;
export default StyledWrapper;

View File

@@ -1,109 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import StyledWrapper from './StyledWrapper';
const Table = ({ minColumnWidth = 1, headers = [], children }) => {
const [activeColumnIndex, setActiveColumnIndex] = useState(null);
const tableRef = useRef(null);
const columns = headers?.map((item) => ({
...item,
ref: useRef()
}));
const updateDivHeights = () => {
if (tableRef.current) {
const height = tableRef.current.offsetHeight;
columns.forEach((col) => {
if (col.ref.current) {
col.ref.current.querySelector('.resizer').style.height = `${height}px`;
}
});
}
};
useEffect(() => {
updateDivHeights();
window.addEventListener('resize', updateDivHeights);
return () => {
window.removeEventListener('resize', updateDivHeights);
};
}, [columns]);
useEffect(() => {
if (tableRef.current) {
const observer = new MutationObserver(updateDivHeights);
observer.observe(tableRef.current, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}
}, [columns]);
const handleMouseDown = (index) => (e) => {
setActiveColumnIndex(index);
};
const handleMouseMove = useCallback((e) => {
const gridColumns = columns.map((col, i) => {
if (i === activeColumnIndex) {
const width = e.clientX - col.ref?.current?.getBoundingClientRect()?.left;
if (width >= minColumnWidth) {
return `${width}px`;
}
}
return `${col.ref.current.offsetWidth}px`;
});
tableRef.current.style.gridTemplateColumns = `${gridColumns.join(' ')}`;
},
[activeColumnIndex, columns, minColumnWidth]);
const removeListeners = useCallback(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', removeListeners);
}, [handleMouseMove]);
const handleMouseUp = useCallback(() => {
setActiveColumnIndex(null);
removeListeners?.();
}, [removeListeners]);
useEffect(() => {
if (activeColumnIndex !== null) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
removeListeners();
};
}, [activeColumnIndex, handleMouseMove, handleMouseUp, removeListeners]);
return (
<StyledWrapper columns={columns}>
<div className="relative">
<table ref={tableRef} className="inherit">
<thead>
<tr>
{columns.map(({ ref, name }, i) => (
<th ref={ref} key={name} title={name}>
<span>{name}</span>
<div
className="resizer absolute cursor-col-resize w-[4px] right-[-2px] top-0 z-10 opacity-50 hover:bg-blue-500 active:bg-blue-500"
onMouseDown={handleMouseDown(i)}
>
</div>
</th>
))}
</tr>
</thead>
{children}
</table>
</div>
</StyledWrapper>
);
};
export default Table;

View File

@@ -25,14 +25,14 @@ const StyledWrapper = styled.div`
table th {
position: relative;
border-bottom: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-bottom: 1px solid ${(props) => props.theme.border.BORDER0};
}
table tr td {
padding: 0.5rem;
text-align: left;
border-top: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-right: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-top: 1px solid ${(props) => props.theme.border.BORDER0};
border-right: 1px solid ${(props) => props.theme.border.BORDER0};
}
tr {
@@ -50,11 +50,11 @@ const StyledWrapper = styled.div`
table tr th {
padding: 0.5rem;
text-align: left;
border-top: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-right: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-top: 1px solid ${(props) => props.theme.border.BORDER0};
border-right: 1px solid ${(props) => props.theme.border.BORDER0};
&:nth-child(1) {
border-left: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
border-left: 1px solid ${(props) => props.theme.border.BORDER0};
}
}
`;

View File

@@ -26,8 +26,8 @@ const StyledWrapper = styled.div`
cursor: default;
&:has(.tag-remove:hover) {
background-color: ${(props) => props.theme.requestTabs.active.bg};
border-color: ${(props) => props.theme.requestTabs.active.border || props.theme.requestTabs.bottomBorder};
background-color: ${(props) => props.theme.background.surface2};
border-color: ${(props) => props.theme.requestTabs.bottomBorder};
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
background-color: ${(props) => props.theme.sidebar.badge};
color: ${(props) => props.theme.text};
`;
export default Wrapper;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Tooltip as ReactToolHint } from 'react-tooltip';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const ToolHint = ({
@@ -20,8 +19,8 @@ const ToolHint = ({
const { theme: contextTheme } = useTheme();
const appliedTheme = theme || contextTheme;
const toolhintBackgroundColor = appliedTheme?.sidebar.badge.bg || 'black';
const toolhintTextColor = appliedTheme?.text || 'white';
const toolhintBackgroundColor = appliedTheme?.background.surface1;
const toolhintTextColor = appliedTheme?.text;
const combinedToolhintStyle = {
...tooltipStyle,
@@ -40,21 +39,19 @@ const ToolHint = ({
<>
{!anchorSelect && <span id={toolhintId} className={className}>{children}</span>}
{anchorSelect && children}
<StyledWrapper theme={appliedTheme}>
<ReactToolHint
{...toolhintProps_final}
content={anchorSelect ? undefined : text}
className="toolhint"
offset={offset}
place={place}
hidden={hidden}
positionStrategy={positionStrategy}
noArrow={true}
delayShow={delayShow}
style={combinedToolhintStyle}
/>
</StyledWrapper>
<ReactToolHint
{...toolhintProps_final}
content={anchorSelect ? undefined : text}
className="toolhint"
offset={offset}
place={place}
hidden={hidden}
positionStrategy={positionStrategy}
noArrow={true}
delayShow={delayShow}
style={combinedToolhintStyle}
opacity={1}
/>
</>
);
};

View File

@@ -88,8 +88,8 @@ const VariablesEditor = ({ collection }) => {
const reactInspectorTheme
= displayedTheme === 'light'
? { ...chromeLight, OBJECT_VALUE_STRING_COLOR: theme.variables.runtime.color }
: { ...chromeDark, OBJECT_VALUE_STRING_COLOR: theme.variables.runtime.color };
? { ...chromeLight, OBJECT_VALUE_STRING_COLOR: theme.text.base }
: { ...chromeDark, OBJECT_VALUE_STRING_COLOR: theme.text.base };
return (
<StyledWrapper className="px-4 py-4 overflow-auto">

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.main-content {
@@ -67,7 +68,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => props.theme.colors.text.green}1A;
background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
@@ -75,7 +76,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => props.theme.colors.text.danger}1A;
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
@@ -93,21 +94,6 @@ const StyledWrapper = styled.div`
min-width: 140px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.15s;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
}
.tab-content {
flex: 1;
overflow: hidden;

View File

@@ -88,7 +88,7 @@ const StyledWrapper = styled.div`
width: 52px;
height: 52px;
border-radius: 8px;
background: ${(props) => props.theme.workspace.card.bg};
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.workspace.border};
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 16px;

View File

@@ -8,6 +8,7 @@ import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { IconFileText, IconEdit, IconX } from '@tabler/icons';
import Button from 'ui/Button';
import toast from 'react-hot-toast';
const WorkspaceDocs = ({ workspace }) => {
@@ -113,9 +114,9 @@ const WorkspaceDocs = ({ workspace }) => {
<li>Key workflows</li>
<li>Resources & FAQs</li>
</ul>
<button className="add-docs-btn" onClick={handleAddDocumentation}>
<Button color="secondary" size="md" onClick={handleAddDocumentation}>
Add Documentation
</button>
</Button>
</div>
)}
</div>

View File

@@ -9,7 +9,7 @@ const Wrapper = styled.div`
.table-container {
overflow-y: auto;
border-radius: 8px;
border: ${(props) => props.theme.workspace.environments.indentBorder};
border: solid 1px ${(props) => props.theme.border.border0};
}
table {
@@ -45,8 +45,8 @@ const Wrapper = styled.div`
td {
padding: 5px 10px !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
@@ -63,8 +63,8 @@ const Wrapper = styled.div`
}
td {
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;

View File

@@ -1,4 +1,5 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
display: flex;
@@ -174,7 +175,7 @@ const StyledWrapper = styled.div`
}
&.active {
background: ${(props) => props.theme.workspace.environments.activeBg};
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
@@ -250,19 +251,18 @@ const StyledWrapper = styled.div`
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
color: ${(props) => props.theme.text};
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}

View File

@@ -11,8 +11,7 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding-top: 10%;
color: ${(props) => props.theme.colors.text.muted};
svg {
@@ -32,22 +31,6 @@ const StyledWrapper = styled.div`
gap: 8px;
}
}
.shared-button {
padding: 5px 10px;
font-size: 12px;
border-radius: 5px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -6,18 +6,19 @@ import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
import Button from 'ui/Button';
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<button className="shared-button" onClick={() => setTab('create')}>
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</button>
<button className="shared-button" onClick={() => setTab('import')}>
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</button>
</Button>
</div>
</div>
);

View File

@@ -97,25 +97,6 @@ const StyledWrapper = styled.div`
.collection-dropdown {
min-width: 120px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.15s ease;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
&.dropdown-item-danger {
color: ${(props) => props.theme.colors.text.danger};
}
}
`;
export default StyledWrapper;

View File

@@ -288,7 +288,7 @@ const CollectionsList = ({ workspace }) => {
<span>Share</span>
</div>
<div
className="dropdown-item dropdown-item-danger"
className="dropdown-item delete-item"
onClick={(e) => {
e.stopPropagation();
handleRemoveCollection(collection);

View File

@@ -6,6 +6,7 @@ import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import Button from 'ui/Button';
import CollectionsList from './CollectionsList';
import WorkspaceDocs from '../WorkspaceDocs';
import StyledWrapper from './StyledWrapper';
@@ -107,18 +108,30 @@ const WorkspaceOverview = ({ workspace }) => {
<div className="quick-actions-section">
<div className="section-title">Quick Actions</div>
<div className="quick-actions-buttons">
<button className="quick-action-btn" onClick={handleCreateCollection}>
<IconPlus size={14} strokeWidth={1.5} />
<span>Create Collection</span>
</button>
<button className="quick-action-btn" onClick={handleOpenCollection}>
<IconFolder size={14} strokeWidth={1.5} />
<span>Open Collection</span>
</button>
<button className="quick-action-btn" onClick={handleImportCollection}>
<IconDownload size={14} strokeWidth={1.5} />
<span>Import Collection</span>
</button>
<Button
color="secondary"
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
onClick={handleCreateCollection}
>
Create Collection
</Button>
<Button
color="secondary"
size="sm"
icon={<IconFolder size={14} strokeWidth={1.5} />}
onClick={handleOpenCollection}
>
Open Collection
</Button>
<Button
color="secondary"
size="sm"
icon={<IconDownload size={14} strokeWidth={1.5} />}
onClick={handleImportCollection}
>
Import Collection
</Button>
</div>
</div>

View File

@@ -1,15 +1,22 @@
import React from 'react';
import { Validator } from 'jsonschema';
import toast from 'react-hot-toast';
import themes from 'themes/index';
import themeSchema from 'themes/schema';
import useLocalStorage from 'hooks/useLocalStorage/index';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState, useMemo } from 'react';
import { ThemeProvider as SCThemeProvider } from 'styled-components';
const validator = new Validator();
export const ThemeContext = createContext();
export const ThemeProvider = (props) => {
const isBrowserThemeLight = window.matchMedia('(prefers-color-scheme: light)').matches;
const [displayedTheme, setDisplayedTheme] = useState(isBrowserThemeLight ? 'light' : 'dark');
const [storedTheme, setStoredTheme] = useLocalStorage('bruno.theme', 'system');
const [themeVariantLight, setThemeVariantLight] = useLocalStorage('bruno.themeVariantLight', 'light');
const [themeVariantDark, setThemeVariantDark] = useLocalStorage('bruno.themeVariantDark', 'dark');
const toggleHtml = () => {
const html = document.querySelector('html');
if (html) {
@@ -45,14 +52,50 @@ export const ThemeProvider = (props) => {
// storedTheme can have 3 values: 'light', 'dark', 'system'
// displayedTheme can have 2 values: 'light', 'dark'
const theme = storedTheme === 'system' ? themes[displayedTheme] : themes[storedTheme];
const themeOptions = Object.keys(themes);
// Get the appropriate variant based on the current display mode
const theme = useMemo(() => {
const isLightMode = displayedTheme === 'light';
const variantName = isLightMode ? themeVariantLight : themeVariantDark;
const fallbackTheme = isLightMode ? themes.light : themes.dark;
const fallbackName = isLightMode ? 'light' : 'dark';
// Check if the variant exists in themes
const selectedTheme = themes[variantName];
if (!selectedTheme) {
// Only show toast if using a non-default variant that doesn't exist
if (variantName !== fallbackName) {
toast.error(`Theme "${variantName}" not found. Using default ${fallbackName} theme.`, {
duration: 4000,
id: `theme-not-found-${variantName}` // Prevent duplicate toasts
});
}
return fallbackTheme;
}
// Validate the theme against the schema
const validationResult = validator.validate(selectedTheme, themeSchema);
if (!validationResult.valid) {
const errors = validationResult.errors?.map((e) => e.stack).join(', ') || 'Unknown validation error';
console.error(`Theme "${variantName}" validation failed:`, errors);
toast.error(`Invalid theme "${variantName}". Using default ${fallbackName} theme.`, {
duration: 4000,
id: `theme-invalid-${variantName}` // Prevent duplicate toasts
});
return fallbackTheme;
}
return selectedTheme;
}, [displayedTheme, themeVariantLight, themeVariantDark]);
const value = {
theme,
themeOptions,
storedTheme,
displayedTheme,
setStoredTheme
setStoredTheme,
themeVariantLight,
setThemeVariantLight,
themeVariantDark,
setThemeVariantDark
};
return (

View File

@@ -1 +0,0 @@
@import 'buttons';

View File

@@ -12,7 +12,6 @@
--color-tab-inactive: rgb(155 155 155);
--color-tab-active-border: #546de5;
--color-layout-border: #dedede;
--color-text-link: #1663bb;
--color-text-danger: rgb(185, 28, 28);
--color-background-danger: #dc3545;
--color-method-get: rgb(5, 150, 105);

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