Merge pull request #6266 from usebruno/feat/copy-response-to-clipboard-5409

added copy button to copy response (#5409)
This commit is contained in:
Sid
2025-12-02 15:47:18 +05:30
committed by GitHub
8 changed files with 203 additions and 7 deletions

View File

@@ -0,0 +1,29 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button {
color: var(--color-tab-inactive);
cursor: pointer;
&:hover {
color: var(--color-tab-active);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.cursor-pointer {
display: flex;
align-items: center;
color: var(--color-tab-inactive);
&:hover {
color: var(--color-tab-active);
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,35 @@
import React, { useRef, forwardRef } from 'react';
import { IconDots } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
const ResponseActions = ({ collection, item }) => {
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((_props, ref) => {
return (
<div ref={ref} className="cursor-pointer">
<IconDots size={18} strokeWidth={1.5} />
</div>
);
});
const handleClose = () => {
menuDropdownTippyRef.current.hide();
};
return (
<StyledWrapper className="ml-2 flex items-center">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-end">
<ResponseClear item={item} collection={collection} asDropdownItem onClose={handleClose} />
<ResponseSave item={item} asDropdownItem onClose={handleClose} />
</Dropdown>
</StyledWrapper>
);
};
export default ResponseActions;

View File

@@ -4,10 +4,11 @@ import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
const ResponseClear = ({ collection, item }) => {
const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => {
const dispatch = useDispatch();
const clearResponse = () =>
const clearResponse = () => {
if (onClose) onClose();
dispatch(
responseCleared({
itemUid: item.uid,
@@ -15,6 +16,16 @@ const ResponseClear = ({ collection, item }) => {
response: null
})
);
};
if (asDropdownItem) {
return (
<div className="dropdown-item" onClick={clearResponse}>
<IconEraser size={16} strokeWidth={1.5} className="icon mr-2" />
Clear
</div>
);
}
return (
<StyledWrapper className="ml-2 flex items-center">

View File

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

View File

@@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
const ResponseCopy = ({ item }) => {
const response = item.response || {};
const [copied, setCopied] = useState(false);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => {
setCopied(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [copied]);
const copyResponse = async () => {
try {
const textToCopy = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2);
await navigator.clipboard.writeText(textToCopy);
toast.success('Response copied to clipboard');
setCopied(true);
} catch (error) {
toast.error('Failed to copy response');
}
};
return (
<StyledWrapper className="ml-2 flex items-center">
<button onClick={copyResponse} disabled={!response.data} title="Copy response to clipboard">
{copied ? (
<IconCheck size={16} strokeWidth={1.5} />
) : (
<IconCopy size={16} strokeWidth={1.5} />
)}
</button>
</StyledWrapper>
);
};
export default ResponseCopy;

View File

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

View File

@@ -16,9 +16,9 @@ import TestResultsLabel from './TestResultsLabel';
import ScriptError from './ScriptError';
import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseActions from 'src/components/ResponsePane/ResponseActions';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import ResponseCopy from 'src/components/ResponsePane/ResponseCopy';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
@@ -187,9 +187,9 @@ const ResponsePane = ({ item, collection }) => {
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
<>
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseCopy item={item} />
<ResponseActions item={item} collection={collection} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />

View File

@@ -0,0 +1,51 @@
import { test, expect } from '../../playwright';
import { closeAllCollections, createCollection } from '../utils/page/actions';
test.describe('Response Pane Actions', () => {
test.afterAll(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('should copy response to clipboard', async ({ page, createTmpDir }) => {
const collectionName = 'response-copy-test';
await test.step('Create collection and request', async () => {
// Create collection
await createCollection(page, collectionName, await createTmpDir(collectionName), { openWithSandboxMode: 'safe' });
// Create request
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
await collection.locator('.collection-actions').hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('copy-test');
await page.locator('#new-request-url .CodeMirror').click();
// Using httpbin.org for a simple JSON response
await page.locator('textarea').fill('https://httpbin.org/json');
await page.getByRole('button', { name: 'Create' }).click();
});
await test.step('Send request and wait for response', async () => {
// Send request
const sendButton = page.getByTestId('send-arrow-icon');
await sendButton.click();
// Wait for response
await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 30000 });
});
await test.step('should copy response to clipboard', async () => {
// Find the copy button
const copyButton = page.locator('button[title="Copy response to clipboard"]');
await expect(copyButton).toBeVisible();
// Click the copy button
await copyButton.click();
// Verify toast notification appears
await expect(page.getByText('Response copied to clipboard')).toBeVisible();
});
});
});