diff --git a/contributing_ru.md b/contributing_ru.md index 061b6056..6636004b 100644 --- a/contributing_ru.md +++ b/contributing_ru.md @@ -34,4 +34,4 @@ Bruno построен с использованием NextJs и React. Мы т - feature/[название функции]: Эта ветка должна содержать изменения для конкретной функции - Пример: feature/dark-mode - bugfix/[название ошибки]: Эта ветка должна содержать только исправления для конкретной ошибки - - Пример bugfix/bug-1 \ No newline at end of file + - Пример bugfix/bug-1 diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js new file mode 100644 index 00000000..e17fc345 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js @@ -0,0 +1,65 @@ +import CodeEditor from 'components/CodeEditor/index'; +import { get } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTheme } from 'providers/theme'; + +const QueryResultPreview = ({ + previewTab, + allowedPreviewModes, + data, + dataBuffer, + formattedData, + item, + contentType, + collection, + mode, + disableRunEventListener, + storedTheme +}) => { + const preferences = useSelector((state) => state.app.preferences); + const dispatch = useDispatch(); + + // Fail safe, so we don't render anything with an invalid tab + if (!allowedPreviewModes.includes(previewTab)) { + return null; + } + + const onRun = () => { + if (disableRunEventListener) { + return; + } + dispatch(sendRequest(item, collection.uid)); + }; + + switch (previewTab) { + case 'preview-web': { + const webViewSrc = data.replace('', ``); + return ( + + ); + } + case 'preview-image': { + return ; + } + default: + case 'raw': { + return ( + + ); + } + } +}; + +export default QueryResultPreview; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index bb29abd3..ba8696a1 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -1,137 +1,106 @@ import React from 'react'; -import get from 'lodash/get'; -import CodeEditor from 'components/CodeEditor'; -import { useTheme } from 'providers/Theme'; -import { useDispatch, useSelector } from 'react-redux'; -import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import classnames from 'classnames'; import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common'; import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror'; +import QueryResultPreview from './QueryResultPreview'; import StyledWrapper from './StyledWrapper'; import { useState } from 'react'; import { useMemo } from 'react'; +import { useEffect } from 'react'; +import { useTheme } from 'providers/Theme/index'; + +const formatResponse = (data, mode) => { + if (!data) { + return ''; + } + + if (mode.includes('json')) { + return safeStringifyJSON(data, true); + } + + if (mode.includes('xml')) { + let parsed = safeParseXML(data, { collapseContent: true }); + if (typeof parsed === 'string') { + return parsed; + } + + return safeStringifyJSON(parsed, true); + } + + if (['text', 'html'].includes(mode) || typeof data === 'string') { + return data; + } + + return safeStringifyJSON(data); +}; const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers, error }) => { - const { storedTheme } = useTheme(); - const preferences = useSelector((state) => state.app.preferences); - const [tab, setTab] = useState('preview'); - const dispatch = useDispatch(); const contentType = getContentType(headers); const mode = getCodeMirrorModeBasedOnContentType(contentType); + const formattedData = formatResponse(data, mode); + const { storedTheme } = useTheme(); - const formatResponse = (data, mode) => { - if (!data) { - return ''; + const allowedPreviewModes = useMemo(() => { + // Always show raw + const allowedPreviewModes = ['raw']; + + if (mode.includes('html') && typeof data === 'string') { + allowedPreviewModes.unshift('preview-web'); + } else if (mode.includes('image')) { + allowedPreviewModes.unshift('preview-image'); } - if (mode.includes('json')) { - return safeStringifyJSON(data, true); + return allowedPreviewModes; + }, [mode, data, formattedData]); + + const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]); + // Ensure the active Tab is always allowed + useEffect(() => { + if (!allowedPreviewModes.includes(previewTab)) { + setPreviewTab(allowedPreviewModes[0]); } + }, [previewTab, allowedPreviewModes]); - if (mode.includes('xml')) { - let parsed = safeParseXML(data, { collapseContent: true }); - - if (typeof parsed === 'string') { - return parsed; - } - - return safeStringifyJSON(parsed, true); - } - - if (['text', 'html'].includes(mode)) { - if (typeof data === 'string') { - return data; - } - - return safeStringifyJSON(data); - } - - if (mode.includes('image')) { - return item.requestSent.url; - } - - // final fallback - if (typeof data === 'string') { - return data; - } - - return safeStringifyJSON(data); - }; - - const value = formatResponse(data, mode); - - const onRun = () => { - if (disableRunEventListener) { - return; - } - dispatch(sendRequest(item, collection.uid)); - }; - - const getTabClassname = (tabName) => { - return classnames(`select-none ${tabName}`, { - active: tabName === tab, - 'cursor-pointer': tabName !== tab - }); - }; - - const getTabs = () => { - if (!mode.includes('html')) { + const tabs = useMemo(() => { + if (allowedPreviewModes.length === 1) { return null; } - return ( - <> -
setTab('raw')}> - Raw -
-
setTab('preview')}> - Preview -
- - ); - }; - - const activeResult = useMemo(() => { - if ( - tab === 'preview' && - mode.includes('html') && - item.requestSent && - item.requestSent.url && - typeof data === 'string' - ) { - // Add the Base tag to the head so content loads properly. This also needs the correct CSP settings - const webViewSrc = data.replace('', ``); - return ( - - ); - } else if (mode.includes('image')) { - return image; - } - - return ( - - ); - }, [tab, collection, storedTheme, onRun, value, mode]); + return allowedPreviewModes.map((previewMode) => ( +
setPreviewTab(previewMode)} + key={previewMode} + > + {previewMode.replace(/-(.*)/, ' ')} +
+ )); + }, [allowedPreviewModes, previewTab]); return (
- {getTabs()} + {tabs}
- {error ? {error} : activeResult} + {error ? ( + {error} + ) : ( + + )}
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index e1cfab2c..aea70de6 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -43,6 +43,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { data={response.data} headers={response.headers} error={response.error} + key={item.filename} /> ); } diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 2c4f28b2..6526c745 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -35,6 +35,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { disableRunEventListener={true} data={responseReceived.data} headers={responseReceived.headers} + key={item.filename} /> ); } diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js index 5c88178a..168c922c 100644 --- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js @@ -76,14 +76,12 @@ const CreateCollection = ({ onClose }) => { name="collectionName" ref={inputRef} className="block textbox mt-2 w-full" - onChange = { - (e) => { - formik.handleChange(e); - if (formik.values.collectionName === formik.values.collectionFolderName) { - formik.setFieldValue("collectionFolderName", e.target.value); - } - } - } + onChange={(e) => { + formik.handleChange(e); + if (formik.values.collectionName === formik.values.collectionFolderName) { + formik.setFieldValue('collectionFolderName', e.target.value); + } + }} autoComplete="off" autoCorrect="off" autoCapitalize="off" diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index c54c3338..ffd66743 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -8,8 +8,10 @@ export const sendNetworkRequest = async (item, collection, environment, collecti resolve({ state: 'success', data: response.data, + // Note that the Buffer is encoded as a base64 string, because Buffers / TypedArrays are not allowed in the redux store + dataBuffer: response.dataBuffer, headers: Object.entries(response.headers), - size: getResponseSize(response), + size: response.size, status: response.status, statusText: response.statusText, duration: response.duration @@ -31,10 +33,6 @@ const sendHttpRequest = async (item, collection, environment, collectionVariable }); }; -const getResponseSize = (response) => { - return response.headers['content-length'] || Buffer.byteLength(safeStringifyJSON(response.data)) || 0; -}; - export const fetchGqlSchema = async (endpoint, environment, request, collection) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 9c9ada3f..2724063d 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -174,6 +174,20 @@ const configureRequest = async (collectionUid, request, envVars, collectionVaria return axiosInstance; }; +const parseDataFromResponse = (response) => { + const dataBuffer = Buffer.from(response.data); + // Parse the charset from content type: https://stackoverflow.com/a/33192813 + const charset = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i.exec(response.headers['Content-Type'] || ''); + // Overwrite the original data for backwards compatability + let data = dataBuffer.toString(charset || 'utf-8'); + // Try to parse response to JSON, this can quitly fail + try { + data = JSON.parse(response.data); + } catch {} + + return { data, dataBuffer }; +}; + const registerNetworkIpc = (mainWindow) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => { @@ -336,6 +350,9 @@ const registerNetworkIpc = (mainWindow) => { // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests + const { data, dataBuffer } = parseDataFromResponse(response); + response.data = data; + // run post-response vars const postResponseVars = get(request, 'vars.res', []); if (postResponseVars?.length) { @@ -448,6 +465,8 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: response.headers, data: response.data, + dataBuffer: dataBuffer.toString('base64'), + size: Buffer.byteLength(dataBuffer), duration: responseTime ?? 0 }; } catch (error) { @@ -674,6 +693,9 @@ const registerNetworkIpc = (mainWindow) => { response = await axiosInstance(request); timeEnd = Date.now(); + const { data, dataBuffer } = parseDataFromResponse(response); + response.data = data; + mainWindow.webContents.send('main:run-folder-event', { type: 'response-received', responseReceived: { @@ -681,20 +703,25 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: Object.entries(response.headers), duration: timeEnd - timeStart, - size: response.headers['content-length'] || getSize(response.data), + dataBuffer: dataBuffer.toString('base64'), + size: Buffer.byteLength(dataBuffer), data: response.data }, ...eventData }); } catch (error) { if (error?.response) { + const { data, dataBuffer } = parseDataFromResponse(error.response); + error.response.data = data; + timeEnd = Date.now(); response = { status: error.response.status, statusText: error.response.statusText, headers: Object.entries(error.response.headers), duration: timeEnd - timeStart, - size: error.response.headers['content-length'] || getSize(error.response.data), + dataBuffer: dataBuffer.toString('base64'), + size: Buffer.byteLength(dataBuffer), data: error.response.data }; diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 3beab80f..6c2d7d4b 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -84,7 +84,8 @@ const prepareRequest = (request, collectionRoot) => { let axiosRequest = { method: request.method, url: request.url, - headers: headers + headers: headers, + responseType: 'arraybuffer' }; axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot);