diff --git a/packages/bruno-app/src/components/Errors/SaveFileErrorModal/StyledWrapper.js b/packages/bruno-app/src/components/Errors/SaveFileErrorModal/StyledWrapper.js new file mode 100644 index 000000000..75681404a --- /dev/null +++ b/packages/bruno-app/src/components/Errors/SaveFileErrorModal/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Errors/SaveFileErrorModal/index.js b/packages/bruno-app/src/components/Errors/SaveFileErrorModal/index.js new file mode 100644 index 000000000..7456d2d03 --- /dev/null +++ b/packages/bruno-app/src/components/Errors/SaveFileErrorModal/index.js @@ -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 ? ( + + + { + setShowModal(false); + }} + disableCloseOnOutsideClick={true} + disableEscapeKey={true} + > +
{error}
+
+
+
+ ) : null} + + ); +}; + +export default SaveFileErrorModal; diff --git a/packages/bruno-app/src/components/FileEditor/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/FileEditor/CodeEditor/StyledWrapper.js new file mode 100644 index 000000000..e2d8f8fa8 --- /dev/null +++ b/packages/bruno-app/src/components/FileEditor/CodeEditor/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/FileEditor/CodeEditor/index.js b/packages/bruno-app/src/components/FileEditor/CodeEditor/index.js new file mode 100644 index 000000000..a6c13914a --- /dev/null +++ b/packages/bruno-app/src/components/FileEditor/CodeEditor/index.js @@ -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 ( + + this.setState({ searchBarVisible: false })} + /> +
{ + this._node = node; + }} + style={{ height: '100%' }} + /> + + ); + } + + 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); + } + }; +} diff --git a/packages/bruno-app/src/components/FileEditor/index.js b/packages/bruno-app/src/components/FileEditor/index.js new file mode 100644 index 000000000..7544a1a08 --- /dev/null +++ b/packages/bruno-app/src/components/FileEditor/index.js @@ -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 ( +
+ + +
+ ); +}; + +export default FileEditor; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index c5d2ccfcd..81683b86e 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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 ( + + + + + + ); + } + const renderQueryUrl = () => { if (isGrpcRequest) { return ; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 1398862a9..ff14ba497 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -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: Beta, onClick: viewOpenApiSync }] : []), diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js index 3810e3673..e12c0b36b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js @@ -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)) diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 93dc70178..9e605fe24 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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(); + } + 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; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js new file mode 100644 index 000000000..63dfd6eb6 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index c2f740f6a..ae93fd109 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -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, diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 308e825b5..d494c84b0 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -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); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 35408f051..fd8772f3d 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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 {