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:
lohit
2025-06-14 20:44:08 +05:30
committed by GitHub
6 changed files with 241 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ const lightTheme = {
colors: {
text: {
green: '#047857',
danger: 'rgb(185, 28, 28)',
danger: '#B91C1C',
muted: '#838383',
purple: '#8e44ad',
yellow: '#d97706'

View File

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

View File

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