mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 13:45:52 +00:00
feat: add prompt for handling large responses (#4866)
* feat: add prompt for handling large responses - Add `formatSize` utility function to format response size - Add unit tests for `formatSize` utility function * fix: update danger color in light theme
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.warning-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 10%;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
margin-bottom: 1rem;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
.size-highlight {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.current-size {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.text.danger}15;
|
||||
}
|
||||
|
||||
.supported-size {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
background: ${(props) => props.theme.colors.text.yellow}15;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { formatSize } from 'utils/common/index';
|
||||
|
||||
const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
|
||||
const saveResponseToFile = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
|
||||
.then(() => {
|
||||
toast.success('Response saved to file');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(get(err, 'error.message') || 'Something went wrong!');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const copyResponse = () => {
|
||||
try {
|
||||
const textToCopy = typeof response.data === 'string'
|
||||
? response.data
|
||||
: JSON.stringify(response.data, null, 2);
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
toast.success('Response copied to clipboard');
|
||||
}).catch(() => {
|
||||
toast.error('Failed to copy response');
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy response');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="warning-container">
|
||||
<div className="warning-icon">
|
||||
<IconAlertTriangle size={45} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="warning-content">
|
||||
<div className="warning-title">
|
||||
Large Response Warning
|
||||
</div>
|
||||
<div className="warning-description">
|
||||
Handling responses over <span className="size-highlight supported-size">{formatSize(10 * 1024 * 1024)}</span> could degrade performance.
|
||||
<br />
|
||||
Size of current response: <span className="size-highlight current-size">{formatSize(responseSize)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="warning-actions">
|
||||
<button
|
||||
className="btn-reveal"
|
||||
onClick={onRevealResponse}
|
||||
title="Show response content"
|
||||
>
|
||||
<IconEye size={18} strokeWidth={1.5} />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
className="btn-save"
|
||||
onClick={saveResponseToFile}
|
||||
disabled={!response.dataBuffer}
|
||||
title="Save response to file"
|
||||
>
|
||||
<IconDownload size={18} strokeWidth={1.5} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn-copy"
|
||||
onClick={copyResponse}
|
||||
disabled={!response.data}
|
||||
title="Copy response to clipboard"
|
||||
>
|
||||
<IconCopy size={18} strokeWidth={1.5} />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default LargeResponseWarning;
|
||||
@@ -11,6 +11,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { getEncoding, uuid } from 'utils/common/index';
|
||||
import LargeResponseWarning from '../LargeResponseWarning';
|
||||
|
||||
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
|
||||
if (data === undefined || !dataBuffer || !mode) {
|
||||
@@ -77,6 +78,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
const contentType = getContentType(headers);
|
||||
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
const responseEncoding = getEncoding(headers);
|
||||
const formattedData = useMemo(
|
||||
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
|
||||
@@ -84,6 +86,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
);
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const responseSize = useMemo(() => {
|
||||
const response = item.response || {};
|
||||
if (typeof response.size === 'number') {
|
||||
return response.size;
|
||||
}
|
||||
|
||||
if (!dataBuffer) return 0;
|
||||
|
||||
try {
|
||||
// dataBuffer is base64 encoded, so we need to calculate the actual size
|
||||
const buffer = Buffer.from(dataBuffer, 'base64');
|
||||
return buffer.length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}, [dataBuffer, item.response]);
|
||||
|
||||
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const debouncedResultFilterOnChange = debounce((e) => {
|
||||
setFilter(e.target.value);
|
||||
}, 250);
|
||||
@@ -160,6 +181,12 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : isLargeResponse && !showLargeResponse ? (
|
||||
<LargeResponseWarning
|
||||
item={item}
|
||||
responseSize={responseSize}
|
||||
onRevealResponse={() => setShowLargeResponse(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 relative">
|
||||
|
||||
@@ -7,7 +7,7 @@ const lightTheme = {
|
||||
colors: {
|
||||
text: {
|
||||
green: '#047857',
|
||||
danger: 'rgb(185, 28, 28)',
|
||||
danger: '#B91C1C',
|
||||
muted: '#838383',
|
||||
purple: '#8e44ad',
|
||||
yellow: '#d97706'
|
||||
|
||||
@@ -196,4 +196,23 @@ export const getEncoding = (headers) => {
|
||||
|
||||
export const multiLineMsg = (...messages) => {
|
||||
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
|
||||
}
|
||||
|
||||
export const formatSize = (bytes) => {
|
||||
// Handle invalid inputs
|
||||
if (isNaN(bytes) || typeof bytes !== 'number') {
|
||||
return '0B';
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return bytes + 'B';
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + 'KB';
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
|
||||
}
|
||||
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
|
||||
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
|
||||
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType, formatSize } from './index';
|
||||
|
||||
describe('common utils', () => {
|
||||
describe('normalizeFileName', () => {
|
||||
@@ -148,4 +148,40 @@ describe('common utils', () => {
|
||||
expect(getContentType(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('should format bytes', () => {
|
||||
expect(formatSize(0)).toBe('0B');
|
||||
expect(formatSize(1023)).toBe('1023B');
|
||||
});
|
||||
|
||||
it('should format kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1.0KB');
|
||||
expect(formatSize(1048575)).toBe('1024.0KB');
|
||||
});
|
||||
|
||||
it('should format megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1.0MB');
|
||||
expect(formatSize(1073741823)).toBe('1024.0MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.0GB');
|
||||
expect(formatSize(1099511627776)).toBe('1024.0GB');
|
||||
});
|
||||
|
||||
it('should format decimal values', () => {
|
||||
expect(formatSize(1126.5)).toBe('1.1KB');
|
||||
expect(formatSize(1153433.6)).toBe('1.1MB');
|
||||
expect(formatSize(1153433600)).toBe('1.1GB');
|
||||
expect(formatSize(1024.1)).toBe('1.0KB');
|
||||
expect(formatSize(1048576.1)).toBe('1.0MB');
|
||||
});
|
||||
|
||||
it('should format invalid inputs', () => {
|
||||
expect(formatSize(null)).toBe('0B');
|
||||
expect(formatSize(undefined)).toBe('0B');
|
||||
expect(formatSize(NaN)).toBe('0B');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user