feat: implement file mode (#8258)

* feat: implement file mode
This commit is contained in:
naman-bruno
2026-06-17 16:34:18 +05:30
committed by GitHub
parent c857d27415
commit ba063f6d82
13 changed files with 824 additions and 5 deletions

View File

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

View File

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

View File

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

View 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);
}
};
}

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

View File

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

View File

@@ -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 }]
: []),

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 {