mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-22 12:15:38 +00:00
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const SaveFileErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Save File Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveFileErrorModal;
|
||||
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
236
packages/bruno-app/src/components/FileEditor/CodeEditor/index.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright (c) 2021 GraphQL Contributors.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getEnvironmentVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
window.JSHINT = JSHINT;
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Keep a cached version of the value, this cache will be updated when the
|
||||
// editor is updated, which can later be used to protect the editor from
|
||||
// unnecessary updates during the update lifecycle.
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/ld+json',
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
showCursorWhenSelecting: true,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
lint: this.lintOptions,
|
||||
readOnly: this.props.readOnly,
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Shift-Cmd-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Shift-Ctrl-M': () => {
|
||||
if (this.props.toggleFileMode) {
|
||||
this.props.toggleFileMode();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'Ctrl-F': (cm) => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this._node.querySelector('.bruno-search-bar > input').focus();
|
||||
}
|
||||
if (!this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: true });
|
||||
}
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
'Cmd-I': 'unfoldAll',
|
||||
'Esc': () => {
|
||||
if (this.state.searchBarVisible) {
|
||||
this.setState({ searchBarVisible: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this._lastScrollTop = this.props.initialScroll || 0;
|
||||
editor.on('scroll', this._onScroll);
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Ensure the changes caused by this update are not interpreted as
|
||||
// user-input changes which could otherwise result in an infinite
|
||||
// event loop.
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.schema !== prevProps.schema && this.editor) {
|
||||
this.editor.options.lint.schema = this.props.schema;
|
||||
this.editor.options.hintOptions.schema = this.props.schema;
|
||||
this.editor.options.info.schema = this.props.schema;
|
||||
this.editor.options.jump.schema = this.props.schema;
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
if (!isEqual(variables, this.variables)) {
|
||||
this.addOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
|
||||
if (this.props.initialScroll !== prevProps.initialScroll && this.editor) {
|
||||
this.editor.scrollTo(null, this.props.initialScroll);
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('scroll', this._onScroll);
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop || 0);
|
||||
}
|
||||
const editorElement = this.editor.getWrapperElement();
|
||||
if (editorElement && editorElement.parentNode) {
|
||||
editorElement.parentNode.removeChild(editorElement);
|
||||
}
|
||||
this.editor = null;
|
||||
this._node = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
>
|
||||
<CodeMirrorSearch
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
/>
|
||||
<div
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay = () => {
|
||||
const mode = this.props.mode || 'application/ld+json';
|
||||
let variables = getEnvironmentVariables(this.props.collection);
|
||||
this.variables = variables;
|
||||
|
||||
defineCodeMirrorBrunoVariablesMode(variables, mode);
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
this.cachedValue = this.editor.getValue();
|
||||
if (this.props.onEdit) {
|
||||
this.props.onEdit(this.cachedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onScroll = () => {
|
||||
if (!this.editor) return;
|
||||
const wrapper = this.editor.getWrapperElement();
|
||||
if (wrapper && wrapper.offsetParent === null) return;
|
||||
this._lastScrollTop = this.editor.getScrollInfo().top;
|
||||
if (typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
};
|
||||
}
|
||||
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
68
packages/bruno-app/src/components/FileEditor/index.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { saveFile } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { toggleCollectionFileMode, updateFileContent } from 'providers/ReduxStore/slices/collections';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const FileEditor = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `file-mode-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const content = item.draft ? item.draft.raw : item.raw || '';
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
updateFileContent({
|
||||
content: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const hasChanges = item.draft != null;
|
||||
|
||||
const onSave = () => {
|
||||
if (!hasChanges) return;
|
||||
dispatch(saveFile(content, item?.uid, collection?.uid));
|
||||
};
|
||||
|
||||
const _toggleFileMode = () => {
|
||||
dispatch(toggleCollectionFileMode({ collectionUid: collection.uid }));
|
||||
};
|
||||
|
||||
const editorMode = item?.type == 'js' ? 'javascript' : item?.type == 'json' ? 'javascript' : 'application/text';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative h-full">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
toggleFileMode={_toggleFileMode}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -19,6 +19,7 @@ import VariablesEditor from 'components/VariablesEditor';
|
||||
import CollectionSettings from 'components/CollectionSettings';
|
||||
import { DocExplorer } from '@usebruno/graphql-docs';
|
||||
|
||||
import FileEditor from 'components/FileEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FolderSettings from 'components/FolderSettings';
|
||||
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
|
||||
@@ -476,6 +477,17 @@ const RequestTabPanel = () => {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if (collection.fileMode) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper className="flex flex-col flex-grow relative p-4 file-mode overflow-hidden">
|
||||
<FileEditor item={item} collection={collection} />
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
if (isGrpcRequest) {
|
||||
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
IconX,
|
||||
IconCheck,
|
||||
IconFolder,
|
||||
IconUpload
|
||||
IconUpload,
|
||||
IconFileCode,
|
||||
IconFileOff
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -220,9 +223,18 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFileModeClick = () => {
|
||||
dispatch(
|
||||
toggleCollectionFileMode({
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick },
|
||||
...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>, onClick: viewOpenApiSync }]
|
||||
: []),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions';
|
||||
import { saveRequest, saveCollectionSettings, saveFolderRoot, saveFile, saveEnvironment } from '../../slices/collections/actions';
|
||||
import { saveGlobalEnvironment } from '../../slices/global-environments';
|
||||
import { flattenItems, isItemARequest, isItemAFolder, findItemInCollection, findCollectionByUid, isItemTransientRequest } from 'utils/collections';
|
||||
|
||||
@@ -52,6 +52,7 @@ const actionsToIntercept = [
|
||||
'collections/updateItemSettings',
|
||||
'collections/addRequestTag',
|
||||
'collections/deleteRequestTag',
|
||||
'collections/updateFileContent',
|
||||
|
||||
// Folder-level actions
|
||||
'collections/addFolderHeader',
|
||||
@@ -129,11 +130,19 @@ const saveExistingDrafts = (dispatch, getState, interval) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check all items (requests and folders) for drafts
|
||||
// Check all items (requests, folders, and file mode) for drafts
|
||||
const allItems = flattenItems(collection.items);
|
||||
allItems.forEach((item) => {
|
||||
if (item.draft) {
|
||||
if (isItemARequest(item)) {
|
||||
// File mode (requests with raw draft content, including empty content)
|
||||
if (collection.fileMode && typeof item.draft.raw === 'string') {
|
||||
// Skip auto-save for transient requests
|
||||
if (isItemTransientRequest(item)) {
|
||||
return;
|
||||
}
|
||||
const key = `file-${item.uid}`;
|
||||
scheduleAutoSave(key, () => dispatch(saveFile(item.draft.raw, item.uid, collection.uid, true)), interval);
|
||||
} else if (isItemARequest(item)) {
|
||||
// Skip auto-save for transient requests
|
||||
if (isItemTransientRequest(item)) {
|
||||
return;
|
||||
@@ -213,6 +222,13 @@ const determineSaveHandler = (actionType, payload, dispatch, getState) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (actionType === 'collections/updateFileContent') {
|
||||
return {
|
||||
key: `file-${itemUid}`,
|
||||
save: () => dispatch(saveFile(payload.content, itemUid, collectionUid, true))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `request-${itemUid}`,
|
||||
save: () => dispatch(saveRequest(itemUid, collectionUid, true))
|
||||
|
||||
@@ -11,6 +11,7 @@ import path, { normalizePath, isPathExternalToBasePath } from 'utils/common/path
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
|
||||
import SaveFileErrorModal from 'components/Errors/SaveFileErrorModal/index';
|
||||
import {
|
||||
findCollectionByUid,
|
||||
findEnvironmentInCollection,
|
||||
@@ -193,6 +194,59 @@ export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch
|
||||
});
|
||||
};
|
||||
|
||||
export const saveFile = (content, itemUid, collectionUid, silent = false) => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
const tempDirectory = state.collections.tempDirectories?.[collectionUid];
|
||||
|
||||
if (!collection) {
|
||||
throw new Error('Collection not found');
|
||||
}
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const item = findItemInCollection(collectionCopy, itemUid);
|
||||
|
||||
// Item is not used to save the bru file
|
||||
// This is to validate if the bru content is associated with a valid item
|
||||
if (!item) {
|
||||
throw new Error('Not able to locate item');
|
||||
}
|
||||
|
||||
const isTransient = tempDirectory && item.pathname.startsWith(tempDirectory);
|
||||
if (isTransient) {
|
||||
if (!silent) {
|
||||
dispatch(addSaveTransientRequestModal({ item, collection }));
|
||||
}
|
||||
throw new Error('Cannot save transient request');
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
try {
|
||||
if (['http-request', 'graphql-request'].includes(item?.type)) {
|
||||
let json = await ipcRenderer.invoke('renderer:convert-to-json', item, content, collection.format);
|
||||
delete json.isTransient;
|
||||
await itemSchema.validate(json);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.custom(<SaveFileErrorModal error={err.message} />);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:save-file', item.pathname, content);
|
||||
if (!silent) {
|
||||
toast.success('File saved successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
toast.error('Failed to save file!');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveMultipleRequests = (items) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { collections } = state.collections;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import reducer, {
|
||||
createCollection,
|
||||
toggleCollectionFileMode,
|
||||
updateFileContent,
|
||||
collectionChangeFileEvent
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
|
||||
const COLLECTION_UID = 'col-1';
|
||||
const ITEM_UID = 'req-1';
|
||||
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
url: 'https://example.com/userinfo',
|
||||
method: 'GET',
|
||||
params: [],
|
||||
headers: [],
|
||||
auth: { mode: 'none' },
|
||||
body: { mode: 'none' },
|
||||
script: {},
|
||||
vars: {},
|
||||
assertions: [],
|
||||
tests: '',
|
||||
docs: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const makeInitialState = ({ fileMode = false, item = {} } = {}) => ({
|
||||
collections: [
|
||||
{
|
||||
uid: COLLECTION_UID,
|
||||
pathname: '/coll',
|
||||
fileMode,
|
||||
items: [
|
||||
{
|
||||
uid: ITEM_UID,
|
||||
name: 'user_info',
|
||||
filename: 'user_info.bru',
|
||||
pathname: '/coll/user_info.bru',
|
||||
type: 'http-request',
|
||||
seq: 1,
|
||||
raw: 'meta {\n name: user_info\n}',
|
||||
draft: null,
|
||||
request: makeRequest(),
|
||||
...item
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
collectionSortOrder: 'default',
|
||||
activeWorkspaceUid: null
|
||||
});
|
||||
|
||||
describe('createCollection', () => {
|
||||
test('initializes fileMode to false', () => {
|
||||
const state = reducer(
|
||||
{ collections: [] },
|
||||
createCollection({ uid: COLLECTION_UID, pathname: '/coll', items: [], brunoConfig: {} })
|
||||
);
|
||||
|
||||
expect(state.collections).toHaveLength(1);
|
||||
expect(state.collections[0].fileMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCollectionFileMode', () => {
|
||||
test('toggles fileMode on and off', () => {
|
||||
let state = makeInitialState();
|
||||
|
||||
state = reducer(state, toggleCollectionFileMode({ collectionUid: COLLECTION_UID }));
|
||||
expect(state.collections[0].fileMode).toBe(true);
|
||||
|
||||
state = reducer(state, toggleCollectionFileMode({ collectionUid: COLLECTION_UID }));
|
||||
expect(state.collections[0].fileMode).toBe(false);
|
||||
});
|
||||
|
||||
test('does nothing for an unknown collection', () => {
|
||||
const initialState = makeInitialState();
|
||||
const state = reducer(initialState, toggleCollectionFileMode({ collectionUid: 'unknown' }));
|
||||
|
||||
expect(state.collections[0].fileMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFileContent', () => {
|
||||
test('creates a draft from the item and sets draft.raw', () => {
|
||||
const state = reducer(
|
||||
makeInitialState(),
|
||||
updateFileContent({
|
||||
collectionUid: COLLECTION_UID,
|
||||
itemUid: ITEM_UID,
|
||||
content: 'meta {\n name: user_info_edited\n}'
|
||||
})
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.draft).not.toBeNull();
|
||||
expect(item.draft.raw).toBe('meta {\n name: user_info_edited\n}');
|
||||
// the draft preserves the structured request of the item
|
||||
expect(item.draft.request).toEqual(item.request);
|
||||
// the item itself is untouched
|
||||
expect(item.raw).toBe('meta {\n name: user_info\n}');
|
||||
});
|
||||
|
||||
test('updates raw on an existing draft without recreating it', () => {
|
||||
let state = reducer(
|
||||
makeInitialState(),
|
||||
updateFileContent({ collectionUid: COLLECTION_UID, itemUid: ITEM_UID, content: 'edit one' })
|
||||
);
|
||||
const firstDraft = state.collections[0].items[0].draft;
|
||||
|
||||
state = reducer(
|
||||
state,
|
||||
updateFileContent({ collectionUid: COLLECTION_UID, itemUid: ITEM_UID, content: 'edit two' })
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.draft.raw).toBe('edit two');
|
||||
expect(item.draft.request).toEqual(firstDraft.request);
|
||||
});
|
||||
|
||||
test('does nothing for an unknown item', () => {
|
||||
const state = reducer(
|
||||
makeInitialState(),
|
||||
updateFileContent({ collectionUid: COLLECTION_UID, itemUid: 'unknown', content: 'edited' })
|
||||
);
|
||||
|
||||
expect(state.collections[0].items[0].draft).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectionChangeFileEvent — raw content', () => {
|
||||
const makeFileEvent = ({ raw, request = makeRequest(), seq = 1 } = {}) => ({
|
||||
file: {
|
||||
meta: {
|
||||
collectionUid: COLLECTION_UID,
|
||||
pathname: '/coll/user_info.bru',
|
||||
name: 'user_info.bru'
|
||||
},
|
||||
data: {
|
||||
uid: ITEM_UID,
|
||||
name: 'user_info',
|
||||
type: 'http-request',
|
||||
seq,
|
||||
raw,
|
||||
request
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('updates item.raw from the file event', () => {
|
||||
const newRaw = 'meta {\n name: user_info_v2\n}';
|
||||
const state = reducer(
|
||||
makeInitialState(),
|
||||
collectionChangeFileEvent(
|
||||
makeFileEvent({ raw: newRaw, request: makeRequest({ url: 'https://example.com/v2' }) })
|
||||
)
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.raw).toBe(newRaw);
|
||||
expect(item.request.url).toBe('https://example.com/v2');
|
||||
});
|
||||
|
||||
test('updates item.raw on a seq-only change', () => {
|
||||
const newRaw = 'meta {\n name: user_info\n seq: 2\n}';
|
||||
const state = reducer(
|
||||
makeInitialState(),
|
||||
collectionChangeFileEvent(makeFileEvent({ raw: newRaw, seq: 2 }))
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.seq).toBe(2);
|
||||
expect(item.raw).toBe(newRaw);
|
||||
});
|
||||
|
||||
test('clears the draft when draft.raw matches the file content (file-mode save round-trip)', () => {
|
||||
let state = makeInitialState({ fileMode: true });
|
||||
const editedRaw = 'meta {\n name: user_info\n}\n\nget {\n url: https://example.com/edited\n}';
|
||||
|
||||
state = reducer(
|
||||
state,
|
||||
updateFileContent({ collectionUid: COLLECTION_UID, itemUid: ITEM_UID, content: editedRaw })
|
||||
);
|
||||
expect(state.collections[0].items[0].draft).not.toBeNull();
|
||||
|
||||
// the file watcher reports the saved file back with the same raw content
|
||||
state = reducer(
|
||||
state,
|
||||
collectionChangeFileEvent(
|
||||
makeFileEvent({ raw: editedRaw, request: makeRequest({ url: 'https://example.com/edited' }) })
|
||||
)
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.draft).toBeNull();
|
||||
expect(item.raw).toBe(editedRaw);
|
||||
});
|
||||
|
||||
test('preserves a draft whose raw content differs from the file content', () => {
|
||||
let state = makeInitialState({ fileMode: true });
|
||||
|
||||
state = reducer(
|
||||
state,
|
||||
updateFileContent({ collectionUid: COLLECTION_UID, itemUid: ITEM_UID, content: 'unsaved edit' })
|
||||
);
|
||||
|
||||
// a change arrives from disk that matches neither the draft structure nor its raw content
|
||||
state = reducer(
|
||||
state,
|
||||
collectionChangeFileEvent(
|
||||
makeFileEvent({
|
||||
raw: 'meta {\n name: user_info_v3\n}',
|
||||
request: makeRequest({ url: 'https://example.com/v3' })
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.raw).toBe('meta {\n name: user_info_v3\n}');
|
||||
expect(item.draft).not.toBeNull();
|
||||
expect(item.draft.raw).toBe('unsaved edit');
|
||||
});
|
||||
|
||||
test('does not clear a genuine draft when raw is undefined on both the draft and the file event', () => {
|
||||
// Simulate a structured-edit draft on an item that has no raw content,
|
||||
// and a file change whose data also carries no raw. The undefined === undefined
|
||||
// match must not wipe the user's unsaved edits.
|
||||
let state = makeInitialState({ item: { raw: undefined } });
|
||||
|
||||
state.collections[0].items[0].draft = {
|
||||
uid: ITEM_UID,
|
||||
name: 'user_info',
|
||||
type: 'http-request',
|
||||
seq: 1,
|
||||
request: makeRequest({ url: 'https://example.com/locally-edited' })
|
||||
};
|
||||
|
||||
state = reducer(
|
||||
state,
|
||||
collectionChangeFileEvent({
|
||||
file: {
|
||||
meta: {
|
||||
collectionUid: COLLECTION_UID,
|
||||
pathname: '/coll/user_info.bru',
|
||||
name: 'user_info.bru'
|
||||
},
|
||||
data: {
|
||||
uid: ITEM_UID,
|
||||
name: 'user_info',
|
||||
type: 'http-request',
|
||||
seq: 1,
|
||||
request: makeRequest({ url: 'https://example.com/disk-change' })
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const item = state.collections[0].items[0];
|
||||
expect(item.draft).not.toBeNull();
|
||||
expect(item.draft.request.url).toBe('https://example.com/locally-edited');
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,7 @@ export const collectionsSlice = createSlice({
|
||||
const collection = action.payload;
|
||||
|
||||
collection.settingsSelectedTab = 'overview';
|
||||
collection.fileMode = false;
|
||||
collection.folderLevelSettingsSelectedTab = {};
|
||||
collection.allTags = []; // Initialize collection-level tags
|
||||
|
||||
@@ -2748,6 +2749,7 @@ export const collectionsSlice = createSlice({
|
||||
currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
|
||||
currentItem.filename = file.meta.name;
|
||||
currentItem.pathname = file.meta.pathname;
|
||||
currentItem.raw = file.data.raw;
|
||||
currentItem.settings = file.data.settings;
|
||||
currentItem.examples = file.data.examples;
|
||||
currentItem.draft = null;
|
||||
@@ -2768,6 +2770,7 @@ export const collectionsSlice = createSlice({
|
||||
examples: file.data.examples,
|
||||
filename: file.meta.name,
|
||||
pathname: file.meta.pathname,
|
||||
raw: file.data.raw,
|
||||
draft: null,
|
||||
partial: file.partial,
|
||||
loading: file.loading,
|
||||
@@ -2853,6 +2856,7 @@ export const collectionsSlice = createSlice({
|
||||
// we don't want to lose the draft in this case
|
||||
if (areItemsTheSameExceptSeqUpdate(item, file.data)) {
|
||||
item.seq = file.data.seq;
|
||||
item.raw = file.data.raw;
|
||||
if (item?.draft) {
|
||||
item.draft.seq = file.data.seq;
|
||||
}
|
||||
@@ -2869,10 +2873,14 @@ export const collectionsSlice = createSlice({
|
||||
item.examples = file.data.examples;
|
||||
item.filename = file.meta.name;
|
||||
item.pathname = file.meta.pathname;
|
||||
item.raw = file.data.raw;
|
||||
|
||||
// Only clear draft if it matches the file content
|
||||
// This preserves characters typed during autosave
|
||||
if (item.draft && areItemsTheSameExceptSeqUpdate(item.draft, file.data)) {
|
||||
// The raw comparison is guarded so an undefined === undefined match
|
||||
// (when neither side has raw content) does not wipe a genuine draft
|
||||
const draftRawMatchesFile = item.draft?.raw !== undefined && item.draft.raw === file.data.raw;
|
||||
if (item.draft && (areItemsTheSameExceptSeqUpdate(item.draft, file.data) || draftRawMatchesFile)) {
|
||||
item.draft = null;
|
||||
}
|
||||
}
|
||||
@@ -3294,6 +3302,27 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleCollectionFileMode: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
if (collection) {
|
||||
collection.fileMode = !collection.fileMode;
|
||||
}
|
||||
},
|
||||
updateFileContent: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
|
||||
if (item) {
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.raw = action.payload.content;
|
||||
}
|
||||
}
|
||||
},
|
||||
collectionAddOauth2CredentialsByUrl: (state, action) => {
|
||||
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo, executionMode } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
@@ -3819,6 +3848,8 @@ export const {
|
||||
updateRunnerConfiguration,
|
||||
updateRequestDocs,
|
||||
updateFolderDocs,
|
||||
toggleCollectionFileMode,
|
||||
updateFileContent,
|
||||
moveCollection,
|
||||
streamDataReceived,
|
||||
collectionAddOauth2CredentialsByUrl,
|
||||
|
||||
@@ -319,6 +319,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
file.partial = false;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
file.data.raw = content;
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
} catch (error) {
|
||||
@@ -360,6 +361,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
});
|
||||
file.partial = false;
|
||||
file.loading = false;
|
||||
file.data.raw = content;
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
}
|
||||
@@ -374,6 +376,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
file.partial = true;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
file.data.raw = content;
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
} finally {
|
||||
@@ -542,6 +545,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
file.data = await parseRequest(content, { format });
|
||||
}
|
||||
|
||||
file.data.raw = content;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'change', file);
|
||||
|
||||
@@ -624,6 +624,20 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:save-file', async (event, pathname, content) => {
|
||||
try {
|
||||
validatePathIsInsideCollection(pathname);
|
||||
|
||||
if (!fs.existsSync(pathname)) {
|
||||
throw new Error(`path: ${pathname} does not exist`);
|
||||
}
|
||||
|
||||
await writeFile(pathname, content);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: Parse file content based on scope type
|
||||
const parseFileByType = async (fileContent, scopeType, format) => {
|
||||
switch (scopeType) {
|
||||
@@ -1720,6 +1734,16 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:convert-to-json', async (event, item, content, format = 'bru') => {
|
||||
try {
|
||||
const jsonContent = await parseRequestViaWorker(content, { format });
|
||||
const json = hydrateRequestWithUuid(jsonContent, item?.pathname);
|
||||
return json;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// add cookie
|
||||
ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user