diff --git a/eslint.config.js b/eslint.config.js index 19439f54e..5b4569600 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,8 +20,8 @@ module.exports = runESMImports().then(() => defineConfig([ parser: require('@typescript-eslint/parser'), parserOptions: { ecmaVersion: 'latest', - sourceType: 'module', - }, + sourceType: 'module' + } }, files: [ './eslint.config.js', @@ -44,11 +44,11 @@ module.exports = runESMImports().then(() => defineConfig([ indent: 2, quotes: 'single', semi: true, - arrowParens: false, jsx: true, }).rules, + '@stylistic/comma-dangle': ['error', 'never'], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], - '@stylistic/arrow-parens': ['error', 'as-needed'], + '@stylistic/arrow-parens': ['error', 'always'], '@stylistic/curly-newline': ['error', { multiline: true, minElements: 2, @@ -60,6 +60,7 @@ module.exports = runESMImports().then(() => defineConfig([ '@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/multiline-ternary': ['off'], '@stylistic/padding-line-between-statements': ['off'], + '@stylistic/jsx-one-expression-per-line': ['off'], '@stylistic/semi-style': ['error', 'last'], '@stylistic/max-len': ['off'], '@stylistic/jsx-one-expression-per-line': ['off'], diff --git a/package-lock.json b/package-lock.json index 1e239eb97..c8d786cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20286,6 +20286,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", @@ -30248,6 +30260,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", + "pidusage": "^4.0.1", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index e87e38d37..5705eecb4 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -12,7 +12,8 @@ import { IconCode, IconChevronDown, IconTerminal2, - IconNetwork + IconNetwork, + IconDashboard, } from '@tabler/icons'; import { closeConsole, @@ -24,10 +25,12 @@ import { updateNetworkFilter, toggleAllNetworkFilters } from 'providers/ReduxStore/slices/logs'; + import NetworkTab from './NetworkTab'; import RequestDetailsPanel from './RequestDetailsPanel'; // import DebugTab from './DebugTab'; import ErrorDetailsPanel from './ErrorDetailsPanel'; +import Performance from '../Performance'; import StyledWrapper from './StyledWrapper'; const LogIcon = ({ type }) => { @@ -384,6 +387,8 @@ const Console = () => { ); case 'network': return ; + case 'performance': + return ; // case 'debug': // return ; default: @@ -484,6 +489,14 @@ const Console = () => { Network + handleTabChange('performance')} + > + + Performance + + {/* handleTabChange('debug')} diff --git a/packages/bruno-app/src/components/Devtools/Performance/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Performance/StyledWrapper.js new file mode 100644 index 000000000..a71599627 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Performance/StyledWrapper.js @@ -0,0 +1,120 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .tab-content { + height: 100%; + display: flex; + flex-direction: column; + background: ${props => props.theme.console.bg}; + } + + .tab-content-area { + flex: 1; + overflow-y: auto; + padding: 16px; + } + + .overview-container { + max-width: 1200px; + margin: 0 auto; + } + + .overview-section { + margin-bottom: 32px; + + &:last-child { + margin-bottom: 0; + } + } + + .section-header { + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid ${props => props.theme.console.border}; + + h3 { + margin: 0 0 4px 0; + font-size: 16px; + font-weight: 600; + color: ${props => props.theme.console.titleColor}; + } + + p { + margin: 0; + font-size: 13px; + color: ${props => props.theme.console.textMuted}; + } + } + + .system-resources { + margin-bottom: 16px; + + h2 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; + color: ${props => props.theme.console.titleColor}; + } + } + + .resource-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + margin-bottom: 16px; + } + + .resource-card { + background: ${props => props.theme.console.headerBg}; + border: 1px solid ${props => props.theme.console.border}; + border-radius: 4px; + padding: 8px; + } + + .resource-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + color: ${props => props.theme.console.titleColor}; + } + + .resource-title { + font-size: 12px; + font-weight: 500; + } + + .resource-value { + font-size: 18px; + font-weight: 600; + color: ${props => props.theme.console.titleColor}; + margin-bottom: 2px; + } + + .resource-subtitle { + font-size: 11px; + color: ${props => props.theme.console.buttonColor}; + } + + .resource-trend { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + margin-top: 8px; + + &.up { + color: #10b981; + } + + &.down { + color: #e81123; + } + + &.stable { + color: ${props => props.theme.console.buttonColor}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Devtools/Performance/index.js b/packages/bruno-app/src/components/Devtools/Performance/index.js new file mode 100644 index 000000000..1de054b1d --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Performance/index.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; +import { + IconCpu, + IconDatabase, + IconClock, + IconServer, + IconChartLine, +} from '@tabler/icons'; + +const Performance = () => { + const { systemResources } = useSelector(state => state.performance); + + const formatBytes = bytes => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatUptime = seconds => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) return `${hours}h ${minutes}m ${secs}s`; + if (minutes > 0) return `${minutes}m ${secs}s`; + return `${secs}s`; + }; + + const SystemResourceCard = ({ icon: Icon, title, value, subtitle, color = 'default', trend }) => ( + + + + {title} + + {value} + {subtitle && {subtitle}} + {trend && ( + 0 ? 'up' : trend < 0 ? 'down' : 'stable'}`}> + + + {trend > 0 ? '+' : ''} + {trend.toFixed(1)} + % + + + )} + + ); + + return ( + + + + + System Resources + + 80 ? 'danger' : systemResources.cpu > 60 ? 'warning' : 'success'} + /> + + 500 * 1024 * 1024 ? 'danger' : 'default'} + /> + + + + + + + + + + ); +}; + +export default Performance; diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js index 0f72e6b07..14eff0709 100644 --- a/packages/bruno-app/src/components/Preferences/General/index.js +++ b/packages/bruno-app/src/components/Preferences/General/index.js @@ -3,6 +3,7 @@ import get from 'lodash/get'; import { useFormik } from 'formik'; import { useSelector, useDispatch } from 'react-redux'; import { savePreferences } from 'providers/ReduxStore/slices/app'; +import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import * as Yup from 'yup'; import toast from 'react-hot-toast'; @@ -35,7 +36,8 @@ const General = ({ close }) => { }) .test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => { return value === undefined || Number(value) >= 0; - }) + }), + defaultCollectionLocation: Yup.string().max(1024) }); const formik = useFormik({ @@ -50,7 +52,8 @@ const General = ({ close }) => { }, timeout: preferences.request.timeout, storeCookies: get(preferences, 'request.storeCookies', true), - sendCookies: get(preferences, 'request.sendCookies', true) + sendCookies: get(preferences, 'request.sendCookies', true), + defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '') }, validationSchema: preferencesSchema, onSubmit: async (values) => { @@ -79,6 +82,9 @@ const General = ({ close }) => { timeout: newPreferences.timeout, storeCookies: newPreferences.storeCookies, sendCookies: newPreferences.sendCookies + }, + general: { + defaultCollectionLocation: newPreferences.defaultCollectionLocation } })) .then(() => { @@ -99,6 +105,19 @@ const General = ({ close }) => { formik.setFieldValue('customCaCertificate.filePath', null); }; + const browseDefaultLocation = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + formik.setFieldValue('defaultCollectionLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('defaultCollectionLocation', ''); + console.error(error); + }); + }; + return ( @@ -231,6 +250,35 @@ const General = ({ close }) => { {formik.touched.timeout && formik.errors.timeout ? ( {formik.errors.timeout} ) : null} + + + Default Collection Location + + + + + Browse + + + + {formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? ( + {formik.errors.defaultCollectionLocation} + ) : null} Save diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js index a0a1e6c09..30169bcd2 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js @@ -12,12 +12,15 @@ import PathDisplay from 'components/PathDisplay'; import { useState } from 'react'; import { IconArrowBackUp, IconEdit } from "@tabler/icons"; import { findCollectionByUid } from 'utils/collections/index'; +import get from 'lodash/get'; const CloneCollection = ({ onClose, collectionUid }) => { const inputRef = useRef(); const dispatch = useDispatch(); const [isEditing, toggleEditing] = useState(false); const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); + const preferences = useSelector((state) => state.app.preferences); + const defaultLocation = get(preferences, 'general.defaultCollectionLocation', ''); const { name } = collection; const formik = useFormik({ @@ -25,7 +28,7 @@ const CloneCollection = ({ onClose, collectionUid }) => { initialValues: { collectionName: `${name} copy`, collectionFolderName: `${sanitizeName(name)} copy`, - collectionLocation: '' + collectionLocation: defaultLocation }, validationSchema: Yup.object({ collectionName: Yup.string() diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index fe180d76d..93d0daca1 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -6,7 +6,7 @@ import filter from 'lodash/filter'; import { useDrop, useDrag } from 'react-dnd'; import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; -import { toggleCollection } from 'providers/ReduxStore/slices/collections'; +import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections'; import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch, useSelector } from 'react-redux'; import { hideHomePage } from 'providers/ReduxStore/slices/app'; @@ -132,6 +132,10 @@ const Collection = ({ collection, searchText }) => { } }; + const handleCollapseFullCollection = () => { + dispatch(collapseFullCollection({ collectionUid: collection.uid })); + }; + const viewCollectionSettings = () => { dispatch( addTab({ @@ -252,7 +256,7 @@ const Collection = ({ collection, searchText }) => { {isLoading ? : null} - + } placement="bottom-start"> { { menuDropdownTippyRef.current.hide(); setShowCloneCollectionModalOpen(true); @@ -310,6 +315,15 @@ const Collection = ({ collection, searchText }) => { > Share + { + menuDropdownTippyRef.current.hide(); + handleCollapseFullCollection(); + }} + > + Collapse + { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js index 0feaa45c8..1de9084ab 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js @@ -19,7 +19,10 @@ const CreateOrOpenCollection = () => { const handleOpenCollection = () => { dispatch(openCollection()).catch( - (err) => console.log(err) && toast.error('An error occurred while opening the collection') + (err) => { + console.log(err); + toast.error('An error occurred while opening the collection'); + } ); }; const CreateLink = () => ( diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js index 3eb6707e0..642cf1ca9 100644 --- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js @@ -1,5 +1,5 @@ import React, { useRef, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; @@ -14,18 +14,21 @@ import Help from 'components/Help'; import { multiLineMsg } from "utils/common"; import { formatIpcError } from "utils/common/error"; import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; +import get from 'lodash/get'; const CreateCollection = ({ onClose }) => { const inputRef = useRef(); const dispatch = useDispatch(); const [isEditing, toggleEditing] = useState(false); + const preferences = useSelector((state) => state.app.preferences); + const defaultLocation = get(preferences, 'general.defaultCollectionLocation', ''); const formik = useFormik({ enableReinitialize: true, initialValues: { collectionName: '', collectionFolderName: '', - collectionLocation: '' + collectionLocation: defaultLocation }, validationSchema: Yup.object({ collectionName: Yup.string() diff --git a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js index f3ec920a2..4eb5dadec 100644 --- a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js +++ b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js @@ -55,7 +55,10 @@ const TitleBar = () => { const handleOpenCollection = () => { dispatch(openCollection()).catch( - (err) => console.log(err) && toast.error('An error occurred while opening the collection') + (err) => { + console.log(err); + toast.error('An error occurred while opening the collection'); + } ); }; diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index 25b984ffe..9ce6de680 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -82,7 +82,7 @@ const StatusBar = () => { dispatch(showPreferences(true))} tabIndex={0} diff --git a/packages/bruno-app/src/components/Welcome/index.js b/packages/bruno-app/src/components/Welcome/index.js index f36b9eb4a..bc9ae536a 100644 --- a/packages/bruno-app/src/components/Welcome/index.js +++ b/packages/bruno-app/src/components/Welcome/index.js @@ -78,7 +78,7 @@ const Welcome = () => { aria-label={t('WELCOME.CREATE_COLLECTION')} > - + {t('WELCOME.CREATE_COLLECTION')} diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 3ad176bd4..1f92f0610 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -26,6 +26,7 @@ import { isElectron } from 'utils/common/platform'; import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments'; import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index'; import { addLog } from 'providers/ReduxStore/slices/logs'; +import { updateSystemResources } from 'providers/ReduxStore/slices/performance'; const useIpcEvents = () => { const dispatch = useDispatch(); @@ -145,6 +146,10 @@ const useIpcEvents = () => { })); }); + const removeSystemResourcesListener = ipcRenderer.on('main:filesync-system-resources', resourceData => { + dispatch(updateSystemResources(resourceData)); + }); + const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) => dispatch(brunoConfigUpdateEvent(val)) ); @@ -209,6 +214,7 @@ const useIpcEvents = () => { removeCollectionOauth2CredentialsUpdatesListener(); removeCollectionLoadingStateListener(); removePersistentEnvVariablesUpdateListener(); + removeSystemResourcesListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index 8ed528073..d5abee753 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -7,6 +7,7 @@ import tabsReducer from './slices/tabs'; import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; import logsReducer from './slices/logs'; +import performanceReducer from './slices/performance'; import { draftDetectMiddleware } from './middlewares/draft/middleware'; const isDevEnv = () => { @@ -25,7 +26,8 @@ export const store = configureStore({ tabs: tabsReducer, notifications: notificationsReducer, globalEnvironments: globalEnvironmentsReducer, - logs: logsReducer + logs: logsReducer, + performance: performanceReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 3269d6a24..7fe639bd1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -25,6 +25,9 @@ const initialState = { font: { codeFont: 'default' }, + general: { + defaultCollectionLocation: '' + }, beta: { grpc: false, websocket: false, 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 822414590..a114776c5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -133,6 +133,13 @@ export const collectionsSlice = createSlice({ state.collections.push(collection); } }, + collapseFullCollection: (state, action) => { + const { collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + collapseAllItemsInCollection(collection); + } + }, updateCollectionMountStatus: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -2931,6 +2938,7 @@ export const { saveRequest, deleteRequestDraft, newEphemeralHttpRequest, + collapseFullCollection, toggleCollection, toggleCollectionItem, requestUrlChanged, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/performance.js b/packages/bruno-app/src/providers/ReduxStore/slices/performance.js new file mode 100644 index 000000000..efd7b01d3 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/performance.js @@ -0,0 +1,28 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + systemResources: { + cpu: 0, + memory: 0, + pid: null, + uptime: 0, + lastUpdated: null, + }, +}; + +export const performanceSlice = createSlice({ + name: 'performance', + initialState, + reducers: { + updateSystemResources: (state, action) => { + state.systemResources = { + ...state.systemResources, + ...action.payload, + lastUpdated: new Date().toISOString(), + }; + }, + }, +}); + +export const { updateSystemResources } = performanceSlice.actions; +export default performanceSlice.reducer; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index 3d9593c44..a91fa706e 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -12,31 +12,130 @@ let CodeMirror; const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const { get } = require('lodash'); -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); +const COPY_ICON_SVG_TEXT = ` + + + + +`; - const renderVarInfo = (token, options, cm, pos) => { - // Extract variable name and value based on token - const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); +const CHECKMARK_ICON_SVG_TEXT = ` + + + +`; - if (variableValue === undefined) { +const COPY_SUCCESS_COLOR = '#22c55e'; + +export const COPY_SUCCESS_TIMEOUT = 1000; + +const getCopyButton = variableValue => { + const copyButton = document.createElement('button'); + + copyButton.className = 'copy-button'; + copyButton.style.backgroundColor = 'transparent'; + copyButton.style.border = 'none'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.style.padding = '2px'; + copyButton.style.opacity = '0.7'; + copyButton.style.transition = 'opacity 0.2s ease'; + copyButton.style.display = 'flex'; + copyButton.style.alignItems = 'center'; + copyButton.style.justifyContent = 'center'; + + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + + let isCopied = false; + + copyButton.addEventListener('mouseenter', () => { + if (isCopied) { return; } - const into = document.createElement('div'); - const descriptionDiv = document.createElement('div'); - descriptionDiv.className = 'info-description'; + copyButton.style.opacity = '1'; + }); - if (options?.variables?.maskedEnvVariables?.includes(variableName)) { - descriptionDiv.appendChild(document.createTextNode('*****')); - } else { - descriptionDiv.appendChild(document.createTextNode(variableValue)); + copyButton.addEventListener('mouseleave', () => { + if (isCopied) { + return; } - into.appendChild(descriptionDiv); + copyButton.style.opacity = '0.7'; + }); - return into; - }; + copyButton.addEventListener('click', e => { + e.stopPropagation(); + + // Prevent clicking if showing success checkmark + if (isCopied) { + return; + } + + navigator.clipboard + .writeText(variableValue) + .then(() => { + isCopied = true; + copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; + copyButton.style.opacity = '1'; + copyButton.style.color = COPY_SUCCESS_COLOR; + copyButton.style.cursor = 'default'; + copyButton.classList.add('copy-success'); + + setTimeout(() => { + isCopied = false; + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + copyButton.style.opacity = '0.7'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.classList.remove('copy-success'); + }, COPY_SUCCESS_TIMEOUT); + }) + .catch(err => { + console.error('Failed to copy to clipboard:', err.message); + }); + }); + + return copyButton; +}; + +export const renderVarInfo = (token, options, cm, pos) => { + // Extract variable name and value based on token + const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); + + if (variableValue === undefined) { + return; + } + + const into = document.createElement('div'); + + const contentDiv = document.createElement('div'); + contentDiv.style.display = 'flex'; + contentDiv.style.alignItems = 'center'; + contentDiv.style.gap = '8px'; + contentDiv.className = 'info-content'; + + const descriptionDiv = document.createElement('div'); + descriptionDiv.className = 'info-description'; + descriptionDiv.style.flex = '1'; + + if (options?.variables?.maskedEnvVariables?.includes(variableName)) { + descriptionDiv.appendChild(document.createTextNode('*****')); + } else { + descriptionDiv.appendChild(document.createTextNode(variableValue)); + } + + const copyButton = getCopyButton(variableValue); + + contentDiv.appendChild(descriptionDiv); + contentDiv.appendChild(copyButton); + into.appendChild(contentDiv); + + return into; +}; + +if (!SERVER_RENDERED) { + CodeMirror = require('codemirror'); CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) { if (old && old !== CodeMirror.Init) { diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index 5002097c2..0a2a161dc 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -1,5 +1,5 @@ import { interpolate } from '@usebruno/common'; -import { extractVariableInfo } from './brunoVarInfo'; +import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo'; // Mock the dependencies jest.mock('@usebruno/common', () => ({ @@ -225,3 +225,120 @@ describe('extractVariableInfo', () => { }); }); }); + +describe('renderVarInfo', () => { + let clipboardText = ''; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // setup mock clipboard + clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn(text => { + if (text === 'cause-clipboard-error') { + return Promise.reject(new Error('Clipboard error')); + } + + clipboardText = text; + + return Promise.resolve(); + }), + }, + configurable: true, + }); + + // mock console.error + console.error = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function setupRender(variables) { + const result = renderVarInfo({ string: '{{apiKey}}' }, { variables }); + const contentDiv = result.querySelector('.info-content'); + const descriptionDiv = contentDiv.querySelector('.info-description'); + const copyButton = contentDiv.querySelector('.copy-button'); + + return { result, contentDiv, descriptionDiv, copyButton }; + } + + describe('popup functionality', () => { + it('should create a popup', () => { + const { result } = setupRender({ apiKey: 'test-value' }); + + expect(result).toBeDefined(); + }); + + it('should create a popup with the correct variable name and value', () => { + const { descriptionDiv } = setupRender({ apiKey: 'test-value' }); + + expect(descriptionDiv.textContent).toBe('test-value'); + }); + + it('should correctly mask the variable value in the popup', () => { + const { descriptionDiv } = setupRender({ + apiKey: 'test-value', + maskedEnvVariables: ['apiKey'], + }); + + expect(descriptionDiv.textContent).toBe('*****'); + }); + }); + + describe('copy button functionality', () => { + it('should create a copy button', () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton).toBeDefined(); + }); + + it('should copy the variable value to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should copy the variable value of masked variables to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should show a success checkmark when the variable value is copied', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + + await copyButton.click(); + + expect(copyButton.classList.contains('copy-success')).toBe(true); + + jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + }); + + it('should log to the console when the variable value is not copied', async () => { + const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' }); + + await copyButton.click(); + + // wait for .catch() microtask to run + await Promise.resolve(); + + expect(clipboardText).toBe(''); + expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error'); + }); + }); +}); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 56ff911a4..884f5dc14 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -66,6 +66,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", + "pidusage": "^4.0.1", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index a6b7a178c..46ea976ef 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -42,15 +42,36 @@ const getCollectionConfigFile = async (pathname) => { }; const openCollectionDialog = async (win, watcher) => { - const { filePaths } = await dialog.showOpenDialog(win, { - properties: ['openDirectory', 'createDirectory'] + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + properties: ['openDirectory', 'createDirectory', 'multiSelections'] }); - if (filePaths && filePaths[0]) { - const resolvedPath = path.resolve(filePaths[0]); - if (isDirectory(resolvedPath)) { - openCollection(win, watcher, resolvedPath); - } else { - console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`); + + if (!canceled && filePaths?.length > 0) { + // Using Set to remove duplicates + const { openCollectionPromises, invalidPaths } = [...new Set(filePaths)].reduce((acc, filePath) => { + const resolvedPath = path.resolve(filePath); + + if (isDirectory(resolvedPath)) { + // Open each valid collection in parallel + acc.openCollectionPromises.push(openCollection(win, watcher, resolvedPath).catch((err) => { + console.error(`[ERROR] Failed to open collection at "${resolvedPath}":`, err.message); + return { error: err, path: resolvedPath }; + })); + } else { + acc.invalidPaths.push(resolvedPath); + console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`); + } + + return acc; + }, + { openCollectionPromises: [], invalidPaths: [] }); + + // Wait for all valid collections to be opened + await Promise.all(openCollectionPromises); + + // Notify about any invalid paths + if (invalidPaths.length > 0) { + win.webContents.send('main:display-error', `Some selected folders could not be opened: ${invalidPaths.join(', ')}`); } } }; @@ -78,7 +99,7 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => { } catch (err) { if (!options.dontSendDisplayErrors) { win.webContents.send('main:display-error', { - error: err.message || 'An error occurred while opening the local collection' + message: err.message || 'An error occurred while opening the local collection' }); } } diff --git a/packages/bruno-electron/src/app/system-monitor.js b/packages/bruno-electron/src/app/system-monitor.js new file mode 100644 index 000000000..48fc27852 --- /dev/null +++ b/packages/bruno-electron/src/app/system-monitor.js @@ -0,0 +1,71 @@ +const pidusage = require('pidusage'); + +class SystemMonitor { + constructor() { + this.intervalId = null; + this.isMonitoring = false; + this.startTime = Date.now(); + } + + start(win, intervalMs = 2000) { + if (this.isMonitoring) { + return; + } + + this.isMonitoring = true; + this.startTime = Date.now(); + + // Emit initial stats + this.emitSystemStats(win); + + // Set up periodic monitoring + this.intervalId = setInterval(() => { + this.emitSystemStats(win); + }, intervalMs); + } + + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.isMonitoring = false; + } + + async emitSystemStats(win) { + try { + const pid = process.pid; + const stats = await pidusage(pid); + const uptime = (Date.now() - this.startTime) / 1000; + + const systemResources = { + cpu: stats.cpu || 0, + memory: stats.memory || 0, + pid: pid, + uptime: uptime, + timestamp: new Date().toISOString(), + }; + + win.webContents.send('main:filesync-system-resources', systemResources); + } catch (error) { + console.error('Error getting system stats:', error); + + // Fallback stats if pidusage fails + const fallbackStats = { + cpu: 0, + memory: process.memoryUsage().rss, + pid: process.pid, + uptime: (Date.now() - this.startTime) / 1000, + timestamp: new Date().toISOString(), + }; + + win.webContents.send('main:filesync-system-resources', fallbackStats); + } + } + + isRunning() { + return this.isMonitoring; + } +} + +module.exports = SystemMonitor; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 267f3fb5e..c36d60ea7 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -45,8 +45,10 @@ const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); const { getDomainsWithCookies } = require('./utils/cookies'); const { cookiesStore } = require('./store/cookies'); const onboardUser = require('./app/onboarding'); +const SystemMonitor = require('./app/system-monitor'); const lastOpenedCollections = new LastOpenedCollections(); +const systemMonitor = new SystemMonitor(); // Reference: https://content-security-policy.com/ const contentSecurityPolicy = [ @@ -202,6 +204,9 @@ app.on('ready', async () => { } mainWindow.webContents.send('main:app-loaded'); + + // Start system monitoring for FileSync + systemMonitor.start(mainWindow); }); // register all ipc handlers @@ -220,6 +225,9 @@ app.on('before-quit', () => { } catch (err) { console.warn('Failed to flush cookies on quit', err); } + + // Stop system monitoring + systemMonitor.stop(); }); app.on('window-all-closed', app.quit); diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 004d0e860..586d9bf95 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -47,6 +47,9 @@ const defaultPreferences = { }, onboarding: { hasLaunchedBefore: false + }, + general: { + defaultCollectionLocation: '' } }; @@ -89,6 +92,9 @@ const preferencesSchema = Yup.object().shape({ }), onboarding: Yup.object({ hasLaunchedBefore: Yup.boolean() + }), + general: Yup.object({ + defaultCollectionLocation: Yup.string().max(1024).nullable() }) }); diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts index 94ae26526..c96a6f50b 100644 --- a/tests/collection/moving-requests/tag-persistence.spec.ts +++ b/tests/collection/moving-requests/tag-persistence.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; test.describe('Tag persistence', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + test('Verify tag persistence while moving requests within a collection', async ({ pageWithUserData: page, createTmpDir }) => { // Create first collection - click dropdown menu first await page.getByLabel('Create Collection').click(); diff --git a/tests/collection/open/open-multiple-collections.spec.ts b/tests/collection/open/open-multiple-collections.spec.ts new file mode 100644 index 000000000..2647d6ef3 --- /dev/null +++ b/tests/collection/open/open-multiple-collections.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { closeAllCollections } from '../../utils/page'; + +test.describe('Open Multiple Collections', () => { + let originalShowOpenDialog; + + test.beforeAll(async ({ electronApp }) => { + // save the original showOpenDialog function + await electronApp.evaluate(({ dialog }) => { + originalShowOpenDialog = dialog.showOpenDialog; + }); + }); + + test.afterAll(async ({ electronApp }) => { + // restore the original showOpenDialog function + await electronApp.evaluate(({ dialog }) => { + dialog.showOpenDialog = originalShowOpenDialog; + }); + }); + + test('Should open multiple collections using Open Collection feature', async ({ + page, + electronApp, + createTmpDir + }) => { + // Create two test collections with proper bruno.json files + const collection1Dir = await createTmpDir('collection-1'); + const collection2Dir = await createTmpDir('collection-2'); + + // Create bruno.json for first collection + const collection1Config = { + version: '1', + name: 'Test Collection 1', + type: 'collection' + }; + // Create bruno.json for second collection + const collection2Config = { + version: '1', + name: 'Test Collection 2', + type: 'collection' + }; + + fs.writeFileSync(path.join(collection1Dir, 'bruno.json'), JSON.stringify(collection1Config, null, 2)); + fs.writeFileSync(path.join(collection2Dir, 'bruno.json'), JSON.stringify(collection2Config, null, 2)); + + // Mock the electron dialog to return multiple folder selections + await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [collection1Dir, collection2Dir] + }); + }, + { collection1Dir, collection2Dir }); + + await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible(); + + // Click on Open Collection(s) button + await page.getByRole('button', { name: 'Open Collection' }).click(); + + // Wait for both collections to appear in the sidebar + const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1'); + const collection2Element = page.locator('#sidebar-collection-name').getByText('Test Collection 2'); + + await expect(collection1Element).toBeVisible(); + await expect(collection2Element).toBeVisible(); + + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Should handle invalid collection path and display error', async ({ + page, + electronApp, + createTmpDir + }) => { + // Directory without bruno.json file + const collection1Dir = await createTmpDir('collection-1'); + const collection2Dir = 'invalid-collection-path'; + + // Mock the electron dialog to return multiple folder selections + await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [collection1Dir, collection2Dir] + }); + }, + { collection1Dir, collection2Dir }); + + await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible(); + + // Click on Open Collection(s) button + await page.getByRole('button', { name: 'Open Collection' }).click(); + + // Verify no collections were opened + await expect(page.locator('#sidebar-collection-name')).toHaveCount(0); + + // Verify invalid collection error + const invalidCollectionError = page.getByText('The collection is not valid (bruno.json not found)').first(); + await expect(invalidCollectionError).toBeVisible(); + + // Verify invalid path error + const invalidPathError = page.getByText('Some selected folders could not be opened').getByText('invalid-collection-path').first(); + await expect(invalidPathError).toBeVisible(); + }); +}); diff --git a/tests/preferences/default-collection-location/collection/bruno.json b/tests/preferences/default-collection-location/collection/bruno.json new file mode 100644 index 000000000..03a26e1f7 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "collection", + "type": "collection" +} diff --git a/tests/preferences/default-collection-location/collection/collection.bru b/tests/preferences/default-collection-location/collection/collection.bru new file mode 100644 index 000000000..408d3bd10 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/collection.bru @@ -0,0 +1,5 @@ +meta { + name: collection + type: collection + version: 1.0.0 +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/collection/environments/Test.bru b/tests/preferences/default-collection-location/collection/environments/Test.bru new file mode 100644 index 000000000..e597f9c24 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/environments/Test.bru @@ -0,0 +1,3 @@ +vars { + host: https://www.httpfaker.org +} diff --git a/tests/preferences/default-collection-location/collection/request.bru b/tests/preferences/default-collection-location/collection/request.bru new file mode 100644 index 000000000..baa0764c4 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/request.bru @@ -0,0 +1,11 @@ +meta { + name: request + type: http + seq: 1 +} + +post { + url: {{host}}/api/echo + body: text + auth: none +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/default-collection-location.spec.js b/tests/preferences/default-collection-location/default-collection-location.spec.js new file mode 100644 index 000000000..8194d854c --- /dev/null +++ b/tests/preferences/default-collection-location/default-collection-location.spec.js @@ -0,0 +1,84 @@ +import { test, expect } from '../../../playwright'; + +test.describe('Default Collection Location Feature', () => { + test('Should hydrate the default location from preferences', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // verify the default location is pre-filled + const defaultLocationInput = page.locator('.default-collection-location-input'); + await expect(defaultLocationInput).toHaveValue('/tmp/bruno-collections'); + + // close the preferences + await page.locator('[data-test-id="modal-close-button"]').click(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should save empty default location', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // clear the default location field + const defaultLocationInput = page.locator('.default-collection-location-input'); + await defaultLocationInput.clear(); + + // save preferences + await page.getByRole('button', { name: 'Save' }).click(); + + // verify success message + await expect(page.locator('text=Preferences saved successfully')).toBeVisible(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should save a valid default location', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // set a default location + const defaultLocationInput = page.locator('.default-collection-location-input'); + + // fill the default location input + await defaultLocationInput.fill('/tmp/bruno-collections'); + + // save preferences + await page.getByRole('button', { name: 'Save' }).click(); + + // verify success message + await expect(page.locator('text=Preferences saved successfully')).toBeVisible(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => { + // test Create Collection modal + await page.locator('[data-testid="create-collection"]').click(); + + // verify the default location is pre-filled + const collectionLocationInput = page.getByLabel('Location'); + await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections'); + + // cancel the collection creation + await page.getByRole('button', { name: 'Cancel' }).click(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => { + // open the clone collection modal + await page.locator('[data-testid="collection-actions"]').click(); + await page.getByTestId('clone-collection').click(); + + // verify the default location is pre-filled + const cloneLocationInput = page.getByLabel('Location'); + await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections'); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); +}); diff --git a/tests/preferences/default-collection-location/init-user-data/collection-security.json b/tests/preferences/default-collection-location/init-user-data/collection-security.json new file mode 100644 index 000000000..e60afe806 --- /dev/null +++ b/tests/preferences/default-collection-location/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/preferences/default-collection-location/collection", + "securityConfig": { + "jsSandboxMode": "developer" + } + } + ] +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/init-user-data/preferences.json b/tests/preferences/default-collection-location/init-user-data/preferences.json new file mode 100644 index 000000000..fa1553037 --- /dev/null +++ b/tests/preferences/default-collection-location/init-user-data/preferences.json @@ -0,0 +1,9 @@ +{ + "maximized": true, + "lastOpenedCollections": ["{{projectRoot}}/tests/preferences/default-collection-location/collection"], + "preferences": { + "general": { + "defaultCollectionLocation": "/tmp/bruno-collections" + } + } +} diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts new file mode 100644 index 000000000..e9f970488 --- /dev/null +++ b/tests/utils/page/actions.ts @@ -0,0 +1,11 @@ +const closeAllCollections = async (page) => { + const numberOfCollections = await page.locator('.collection-name').count(); + + for (let i = 0; i < numberOfCollections; i++) { + await page.locator('.collection-name').first().locator('.collection-actions').click(); + await page.locator('.dropdown-item').getByText('Close').click(); + await page.getByRole('button', { name: 'Close' }).click(); + } +}; + +export { closeAllCollections }; diff --git a/tests/utils/page/index.ts b/tests/utils/page/index.ts new file mode 100644 index 000000000..485f1b10a --- /dev/null +++ b/tests/utils/page/index.ts @@ -0,0 +1 @@ +export * from './actions'; diff --git a/tests/utils/pageUtils/actions.js b/tests/utils/page/navigation.ts similarity index 100% rename from tests/utils/pageUtils/actions.js rename to tests/utils/page/navigation.ts diff --git a/tests/utils/pageUtils/index.js b/tests/utils/pageUtils/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/utils/pageUtils/navigation.js b/tests/utils/pageUtils/navigation.js deleted file mode 100644 index e69de29bb..000000000