Compare commits

...

34 Commits

Author SHA1 Message Date
shubh-bruno
77916019cd fix: status & statusText swap (#7589)
* fix: status & statusText swap

* chore: typo

* test: tests for swapping status and statusText

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-02 21:01:10 +05:30
Pooja
02aa669578 fix: convert non-string variable values to strings during postman import (#7476) 2026-04-02 21:00:39 +05:30
sanish chirayath
d8809e09e7 Fix: ensure string authvalues, string header processing (#7646)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.

* feat: enhance header parsing in Postman to Bruno conversion

- Added `parseStringHeader` and `normalizeHeaders` functions to handle various header formats, including string headers and concatenated strings.
- Updated the request and response handling in `importPostmanV2CollectionItem` to utilize the new header normalization logic.
- Introduced tests to verify correct parsing of string headers, including cases with no values and concatenated headers.

* refactor: enhance ensureString function for flexible fallback values

- Updated the `ensureString` function to accept a fallback parameter, allowing for customizable default values instead of a fixed empty string for null/undefined inputs.
- Modified the usage of `ensureString` in the `processAuth` function to utilize the new fallback feature for various authentication fields, improving the handling of optional values.

* refactor: update ensureString function to handle empty values

- Modified the `ensureString` function to return the fallback for null, undefined, or empty string values, enhancing its flexibility in handling various input scenarios.

* chore: update ESLint configuration and enhance Postman to Bruno conversion tests

- Added 'no-case-declarations' rule to ESLint configuration to enforce stricter coding standards.
- Modified the `processAuth` function to ensure proper block scoping for OAuth2 case handling.
- Improved header parsing logic to check for string type in content-type header.
- Added new tests to verify conversion of numeric authentication values to strings in both array-backed and object-backed formats during Postman to Bruno transformation.

* chore: update ESLint configuration to enforce stricter rules

- Added 'no-case-declarations' rule to ESLint configuration to enhance code quality.
- Adjusted existing rules for consistency and clarity in the configuration.

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-04-02 20:59:32 +05:30
sanish chirayath
04fdd6f8a9 feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion (#7644)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.
2026-04-02 20:58:24 +05:30
Sid
3097f3aa76 fix: update system proxy fetching to use finally (#7652)
* fix: update system proxy fetching to use finally for improved reliability

* Update packages/bruno-electron/src/index.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-02 20:56:34 +05:30
Sid
9c3eabdda2 chore: add a promise based wait group for the shell variables (#7647) 2026-04-02 20:56:34 +05:30
Sid
7c4da8b8bc security: fix all critical vuln dependency reports (#7645)
* chore: remove form-data vuln

* chore: stale aws in lock

* chore: other critical vulns

* chore: correct deps
2026-04-01 23:42:06 +05:30
Chirag Chandrashekhar
1e4c3464d2 fix: app crash on clicking close button (#7637)
* fix: app crash on clicking close button \n Added collection, workspace, and api spec watcher cleanup on app close method

* fix: close file watchers before app exit to prevent crash on macOS

Close all chokidar file watchers (collection, workspace, apiSpec) before
the Node environment is torn down. The native FSEvents watchers run on
their own threads and their cleanup races with FreeEnvironment, causing
an abort when fse_instance_destroy tries to lock a destroyed mutex.

Watchers are closed in both mainWindow.on('close') and app.on('before-quit')
to cover the native close button path and the app.exit() path.

* fix: move watcher cleanup from close handler to before-quit only

The close event is cancelable — if the user cancels the unsaved changes
dialog, watchers would remain closed for the rest of the session.
Move closeAllWatchers() to before-quit which only fires on actual quit.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:39:53 +05:30
lohit
5695f69430 fix: recreate HTTP/HTTPS agents on redirect to prevent stale agent reuse (#7597) (#7615)
When a request redirected from HTTP to HTTPS (or vice versa), the
original httpAgent/httpsAgent leaked into the redirect config. The
httpsAgent — which carries custom CA certificates and TLS options — was
never created for the redirect URL, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Changes:
- setupProxyAgents (electron) now deletes stale agents at the top of
  every call so they are always recreated for the current URL
- setupProxyAgents extracted to bruno-cli/proxy-util.js (mirrors the
  electron version) and called on every redirect in the CLI path
- Removed the else-branch in bruno-requests/http-https-agents.ts that
  only created one agent based on initial protocol
- Added HTTP→HTTPS redirect test server and request to the
  custom-ca-certs SSL test suite
2026-04-01 23:39:29 +05:30
Sid
d0bbac6b66 fix(security): santize HTML before being rendered in documentation blocks (#7598)
* fix: purify markdown before rendering

* chore: resolve stale html
2026-04-01 23:35:53 +05:30
Abhishek S Lal
51e2c045ec fix: re-apply masking in MultiLineEditor and SingleLineEditor after setValue() to preserve CodeMirror marks (#7585) 2026-04-01 23:35:45 +05:30
Pooja
b585c3e943 fix: preserve query params without values by not appending = sign (#7567)
* fix: preserve query params without values by not appending = sign

* fix: parseCurlCommand test
2026-04-01 23:35:38 +05:30
Chirag Chandrashekhar
8150a21395 fix: preserve user-defined boundary in multipart/mixed Content-Type header (#7531)
* fix: preserve user-defined boundary in multipart/mixed Content-Type header

When users specify a boundary parameter in their Content-Type header for
multipart/mixed requests with TEXT body mode, Bruno now preserves the
user-defined boundary instead of generating a new one.

Fixes: https://github.com/usebruno/bruno/issues/7523

* updated the test to use local server and changed the request method to GET

* fix: handle quoted boundary values in Content-Type header extraction

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:35:00 +05:30
Pragadesh-45
0470e8d1a7 feat: include pinned data in filtering for more accurate results in env variables search (#7513) 2026-03-18 18:53:46 +05:30
naman-bruno
031b373bac fix: clear draft on save and update dependencies in useEffect (#7512) 2026-03-18 18:53:41 +05:30
naman-bruno
586fd6b7f6 refactor: optimize formik value handling and improve save conditions (#7507)
* refactor: optimize formik value handling and improve save conditions

* fix
2026-03-18 18:53:35 +05:30
naman-bruno
51765da0b1 refactor: optimize debounced save functionality (#7495) 2026-03-18 18:51:18 +05:30
sanish chirayath
5b02aad92a feat(bruno-js): add hasCookie function to cookie jar shim for improved cookie management (#7501) 2026-03-18 18:49:21 +05:30
Abhishek S Lal
606d03180f fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)
* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
2026-03-18 18:48:38 +05:30
Chirag Chandrashekhar
a86551ad27 fix(RequestTabPanel): update loading message for better user feedback (#7492)
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:48:10 +05:30
Abhishek S Lal
60f8611dd7 refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
2026-03-18 18:46:03 +05:30
Bohdan
09be7131cc add missing color to scrollbar-color property (#7481) 2026-03-18 18:45:10 +05:30
Thomas
d45a975335 feat: remove .bru reference in error message (#7479)
Co-authored-by: Thomas Vackier <thomas.vackier@inthepocket.com>
2026-03-18 18:44:51 +05:30
Abhishek S Lal
6f82eae80f feat: improve OpenAPI Sync tab UX and fix sync flow bugs (#7467)
* fix: specify OpenAPI 3.x in error messages for file uploads and URL validation

Updated error messages in ConnectSpecForm and ConnectionSettingsModal to clarify that only OpenAPI 3.x specifications are valid. Enhanced useOpenAPISync hook to reflect the same specificity in error handling for invalid URLs.

* feat(OpenAPISpecTab): add pretty-printing for JSON content in API spec viewer

Implemented a new function to pretty-print JSON content for improved readability in the OpenAPISpecTab component. This enhancement ensures that JSON specifications are displayed in a more user-friendly format while leaving YAML content unchanged.

* feat(OpenAPISyncHeader): resolve and display absolute file paths for local sources

Added functionality to resolve relative file paths to absolute paths for better user experience in the OpenAPISyncHeader component. Implemented state management and side effects to handle path resolution based on the source URL, enhancing the display of local file paths.

* feat(OpenAPISyncTab): enhance collection status display and add endpoint counting utility

Refactored the CollectionStatusSection to streamline the display of collection drift status, integrating loading states and improved messaging for initial sync scenarios. Introduced a new utility function to count HTTP endpoints in OpenAPI specifications, enhancing the overall functionality of the OpenAPISyncTab. Additionally, updated the OpenAPISyncHeader and OverviewSection to utilize stored specification metadata for better user experience.

* refactor: improve OpenAPI Sync endpoint handling

- Enhanced the logic for adding new requests by ensuring existing files are verified before removal to prevent accidental deletions.
- Streamlined the process of adding new endpoints, including checks for existing files and merging requests to maintain user customizations.
- Added comments for clarity on the purpose of changes, particularly regarding filename collision prevention and file content verification.

* style(OpenAPISyncTab): update styles for improved visual feedback

- Changed background color for the 'type-spec-modified' class to a warning color for better distinction.
- Updated text color and background for the SyncReviewPage to enhance readability and visual hierarchy.
- Adjusted default expanded states for endpoint sections to improve user experience during sync reviews.

* chore: update .gitignore and enhance OpenAPISyncTab components

- Added new entries to .gitignore for agent-related files and skills-lock.json.
- Modified StyledWrapper to improve overflow handling and added sticky headers for better visibility.
- Introduced loading state in SpecDiffModal with a spinner for improved user feedback during rendering.

* feat(OpenAPISpecTab): integrate fast-json-format for improved JSON rendering

- Replaced the JSON parsing and stringifying logic with fast-json-format for better performance in pretty-printing API specifications.
- Updated StyledWrapper in OpenAPISyncTab to change background and text colors for enhanced visual consistency.
- Modified DisconnectSyncModal button to include a secondary color for improved visibility during user interactions.

* fix(OpenAPISyncTab): correct punctuation in status messages and subtitles

- Removed unnecessary trailing periods in messages related to syncing and restoring specifications across CollectionStatusSection and OverviewSection components.
- Updated SyncReviewPage to correct grammatical error in the description of spec updates.

* fix(OpenAPISyncTab): update URL validation to use isHttpUrl

- Replaced isValidUrl with isHttpUrl in ConnectSpecForm and ConnectionSettingsModal components to ensure only valid HTTP URLs are accepted.
- Updated the logic for enabling the save button based on the new URL validation method.

* fix(OpenAPISyncTab): normalize source URL before validation

- Trimmed the source URL in ConnectionSettingsModal to ensure consistent validation with isHttpUrl.
- Updated state initialization for URL and filePath to use the normalized source URL, improving handling of user input.
2026-03-18 18:44:28 +05:30
Abhishek S Lal
ab2326deb3 fix(collection-watcher): prevent crash when deleting collections (#7470)
* fix(collection-watcher): guard against events firing after collection deletion

When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to
bruno.json which creates buffered chokidar events (80ms stabilityThreshold).
If the collection directory is removed before those events fire,
getCollectionFormat() throws "No collection configuration found" for each
.bru file in the collection.

Add fs.existsSync(collectionPath) guards in the change, unlink, and
unlinkDir handlers to bail out early when the collection root no longer exists.

* fix(workspaces): ensure collection watcher stops before deletion

Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed.

* refactor(workspaces): remove redundant collection watcher logic during deletion

Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity.

* test(collection): add integration test for collection deletion functionality

Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features.

* feat(collection): implement deleteCollectionFromOverview utility function

Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity.

* fix(collection-watcher): add guards for collection path existence and error handling

Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations.
2026-03-18 18:44:04 +05:30
naman-bruno
5a4d337ed3 feat: integrate deferred loading for saving state in DotEnvFileEditor (#7463) 2026-03-18 18:43:48 +05:30
naman-bruno
cc197e0c30 feat: implement temporary workspace creation and confirmation flow (#7462)
* feat: implement temporary workspace creation and confirmation flow

* fixes
2026-03-18 18:43:30 +05:30
Pragadesh-45
9fa6acca4e refactor: simplify environment list actions and improve styling (#7459) 2026-03-18 18:43:05 +05:30
naman-bruno
da892243d2 refactor: update path imports to use utils/common/path (#7440) 2026-03-18 18:42:43 +05:30
Pooja
994b60678e fix: multipart header check (#7444)
* fix: multipart header check

* fix
2026-03-18 18:42:29 +05:30
lohit
e001b6ba51 fix: cookie wrapper callback mode returns never-resolving Promise (#7442)
* fix: cookie wrapper callback mode returns never-resolving Promise

tough-cookie's createPromiseCallback() intentionally never resolves the
returned Promise when a callback is provided — only the callback fires.
The cookie jar wrapper was propagating this never-resolving Promise via
`return cookieJar.getCookies(url, cb)` in callback-mode paths. When user
scripts did `await jar.getCookie(url, name, callback)` in the Node VM
(developer sandbox), the await hung forever, blocking the CLI runner.

Fix: drop the return value in all callback-mode paths so the wrapper
returns void (undefined). `await undefined` resolves immediately.

Affected methods: getCookie, getCookies, hasCookie, clear, deleteCookies.

* fix: validation-error callback paths also return void instead of callback result

The validation guards (e.g. missing URL) did `return callback(error)` which
leaks whatever the user's callback returns. Apply the same pattern used for
the main callback paths: call the callback, then return void.

Also makes deleteCookie's `return executeDelete(callback)` consistent
(executeDelete already returns void, but the explicit pattern is clearer).
2026-03-18 18:42:13 +05:30
Sid
59453536a6 revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)
* Revert "feat(phase-1): allow user to customize keybindings (#7163)"

This reverts commit 14532b48a6.

* Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)"

This reverts commit 5151d29aac.
2026-03-18 18:41:43 +05:30
Chirag Chandrashekhar
7fc4ff274d fix: normalize paths when comparing workspace and redux collection paths on Windows (#7436)
Without path normalization, collections appear stuck in "mounting" state on Windows
because workspace YAML uses forward slashes while Redux uses backslashes.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:40:36 +05:30
Abhishek S Lal
663ece708e Feat/openapi sync beta tag (#7461)
* feat: introduce OpenAPI Sync beta feedback feature

- Added a feedback section in the OpenAPISyncTab and ConnectSpecForm to encourage user input during the beta phase.
- Styled the feedback message and button for better visibility.
- Updated the beta features list to include OpenAPI Sync and adjusted related components to reflect its beta status with appropriate badges.
- Enhanced the StatusBadge component to support a new 'xs' size for better integration in various UI elements.

* feat: integrate OpenAPI Sync beta feature toggle

- Updated the ImportCollectionLocation component to conditionally enable the "Check for Spec Updates" option based on the OpenAPI Sync beta feature status.
- Modified default preferences to disable OpenAPI Sync by default, ensuring users are not prompted for updates unless explicitly enabled.

* feat: enhance beta features integration in Preferences

- Updated the BETA_FEATURES array to use constants from utils/beta-features for better maintainability.
- Improved the handling of beta preferences by merging new preferences with existing ones, ensuring a smoother user experience when toggling features.

* feat: enhance OpenAPI Sync polling with beta feature toggle

- Integrated a beta feature toggle for OpenAPI Sync polling, allowing conditional activation based on user settings.
- Updated the pollingEnabled logic to incorporate the new beta feature status, ensuring better control over sync behavior.
2026-03-18 18:39:37 +05:30
131 changed files with 9377 additions and 9251 deletions

3
.gitignore vendored
View File

@@ -51,6 +51,9 @@ bruno.iml
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/

View File

@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error'
'no-undef': 'error',
'no-case-declarations': 'error'
}
},
{

9153
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -1,8 +0,0 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

View File

@@ -39,7 +39,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"graphql-request": "4.2.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -102,7 +102,7 @@
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",

View File

@@ -160,7 +160,6 @@ const AppTitleBar = () => {
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}

View File

@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -221,9 +217,6 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(editor, this);
}
}
@@ -295,12 +288,6 @@ export default class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);

View File

@@ -99,24 +99,6 @@ const Wrapper = styled.div`
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {

View File

@@ -31,15 +31,6 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -51,8 +42,7 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
@@ -60,7 +50,7 @@ const EnvironmentVariablesTable = ({
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -103,6 +93,13 @@ const EnvironmentVariablesTable = ({
setTableHeight(h);
}, []);
const handleRowFocus = useCallback((uid) => {
setPinnedData((prev) => ({
query: searchQuery,
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
}));
}, [searchQuery]);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
@@ -205,6 +202,10 @@ const EnvironmentVariablesTable = ({
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
useEffect(() => {
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -358,6 +359,7 @@ const EnvironmentVariablesTable = ({
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
@@ -376,7 +378,7 @@ const EnvironmentVariablesTable = ({
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, setIsModified]);
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
@@ -418,12 +420,20 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
const valueText
= typeof variable.value === 'string'
? variable.value
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
? String(variable.value)
: '';
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
}, [formik.values, searchQuery, pinnedData]);
const isSearchActive = !!searchQuery?.trim();
@@ -460,11 +470,6 @@ const EnvironmentVariablesTable = ({
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
return (
<>
@@ -475,8 +480,7 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
onChange={formik.handleChange}
/>
)}
</td>
@@ -494,29 +498,25 @@ const EnvironmentVariablesTable = ({
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
handleNameBlur(actualIndex);
}}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
style={{ width: columnWidths.value }}
>
<div className="overflow-hidden grow w-full relative">
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
@@ -524,7 +524,7 @@ const EnvironmentVariablesTable = ({
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={isSearchActive || typeof variable.value !== 'string'}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
@@ -555,14 +555,13 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}

View File

@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import useDeferredLoading from 'hooks/useDeferredLoading';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const showSaving = useDeferredLoading(isSaving, 200);
const formikRef = useRef(null);
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
padding: 9px 20px 8px 20px;
flex-shrink: 0;
.title {

View File

@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
border-radius: 6px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
padding: 8px;
}
.section-header {
margin-inline: 4px !important;
padding-left: 6px !important;
border-radius: 6px ;
padding-right: 3px !important;
padding-block: 4px !important;
}
.environments-list {
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 5px;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {

View File

@@ -49,7 +49,6 @@ const EnvironmentList = ({
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
@@ -84,6 +83,8 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
@@ -92,10 +93,10 @@ const EnvironmentList = ({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
@@ -558,51 +559,65 @@ const EnvironmentList = ({
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
className="btn-action"
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleCreateEnvClick();
}}
title="Search environments"
title="Create environment"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleImportClick();
}}
title="Import environment"
>
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleExportClick();
}}
title="Export environment"
>
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button
className="env-list-search-clear"
title="Clear search"
onClick={() => setSearchText('')}
onMouseDown={(e) => e.preventDefault()}
>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
);
})
}),

View File

@@ -27,7 +27,8 @@ const ManageWorkspace = () => {
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
return sortWorkspaces(persistedWorkspaces, preferences);
}, [workspaces, preferences]);
const handleBack = () => {
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}

View File

@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = md.render(content || '');
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
return (
<StyledWrapper>
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
dangerouslySetInnerHTML={{ __html: cleanHTML }}
onClick={handleOnClick}
onDoubleClick={handleOnDoubleClick}
/>

View File

@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -49,16 +45,16 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
// 'Ctrl-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
// 'Cmd-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
@@ -94,9 +90,6 @@ class MultiLineEditor extends Component {
setupLinkAware(this.editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(this.editor, this);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -171,6 +164,10 @@ class MultiLineEditor extends Component {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
@@ -186,12 +183,6 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}

View File

@@ -1,8 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
*/
const prettyPrintSpec = (content) => {
if (!content) return content;
if (content.trimStart()[0] !== '{') return content;
try {
return fastJsonFormat(content);
} catch {
return content;
}
};
const OpenAPISpecTab = ({ collection }) => {
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -19,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
collectionPath: collection.pathname,
sourceUrl
collectionPath: collection.pathname
});
if (result.error) {
// Local file not found — fall back to fetching from remote URL
@@ -37,14 +50,14 @@ const OpenAPISpecTab = ({ collection }) => {
}
});
if (fetchResult.content) {
setSpecContent(fetchResult.content);
setSpecContent(prettyPrintSpec(fetchResult.content));
setIsRemote(true);
return;
}
}
setError(result.error);
} else {
setSpecContent(result.content);
setSpecContent(prettyPrintSpec(result.content));
}
} catch (err) {
setError(err.message || 'Failed to read spec file');

View File

@@ -5,15 +5,15 @@ import {
IconTrash,
IconArrowBackUp,
IconExternalLink,
IconClock,
IconInfoCircle
IconAlertTriangle,
IconInfoCircle,
IconLoader2
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Modal from 'components/Modal';
import EndpointChangeSection from '../EndpointChangeSection';
import EndpointItem from '../EndpointChangeSection/EndpointItem';
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
import useEndpointActions from '../hooks/useEndpointActions';
@@ -24,7 +24,9 @@ const CollectionStatusSection = ({
specDrift,
storedSpec,
lastSyncDate,
onOpenEndpoint
onOpenEndpoint,
isLoading,
onTabSelect
}) => {
const {
pendingAction, setPendingAction,
@@ -39,7 +41,8 @@ const CollectionStatusSection = ({
} = useEndpointActions(collection, collectionDrift, reloadDrift);
const spec = storedSpec || specDrift?.newSpec;
const hasDrift = !!collectionDrift && (collectionDrift.modified?.length > 0
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|| collectionDrift.missing?.length > 0
|| collectionDrift.localOnly?.length > 0);
@@ -85,12 +88,6 @@ const CollectionStatusSection = ({
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">
{bannerState.message}
{bannerState.version && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastSyncDate && (
<span className="checked-text"> &middot; Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
)}
</span>
{bannerState.badges && (
<span className="banner-details">
@@ -113,7 +110,7 @@ const CollectionStatusSection = ({
{hasDrift && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What's tracked:</span> Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
</div>
)}
@@ -211,11 +208,27 @@ const CollectionStatusSection = ({
)}
/>
</div>
) : isLoading ? (
<div className="sync-review-empty-state mt-5">
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your collection with the last synced spec...</p>
</div>
) : !hasStoredSpec ? (
<div className="sync-review-empty-state mt-5">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
<p>{lastSyncDate
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
: 'Once you sync your collection with the spec, local changes will appear here.'}
</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
</div>
) : (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No changes in collection</h4>
<p>The collection matches the last synced spec. Nothing to review.</p>
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
</div>
)}
{/* Action confirmation modal */}

View File

@@ -1,7 +1,7 @@
import { useState, useRef } from 'react';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import { isValidUrl } from 'utils/url/index';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
@@ -77,7 +77,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
setError('The selected file is not a valid OpenAPI specification');
setError('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const filePath = window.ipcRenderer.getFilePath(file);
@@ -100,7 +100,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
<Button
type="submit"
size="sm"
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
loading={isLoading}
>
Connect
@@ -124,6 +124,17 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
</div>
))}
</div>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
);
};

View File

@@ -2,17 +2,18 @@ import { useState, useRef } from 'react';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import Modal from 'components/Modal';
import { isValidUrl } from 'utils/url/index';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const isUrl = isValidUrl(sourceUrl);
const normalizedSourceUrl = (sourceUrl || '').trim();
const isUrl = isHttpUrl(normalizedSourceUrl);
const initialMode = isUrl ? 'url' : 'file';
const [mode, setMode] = useState(initialMode);
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
const [isSaving, setIsSaving] = useState(false);
@@ -21,7 +22,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
const intervals = [5, 15, 30, 60];
const effectiveSource = mode === 'file' ? filePath : url.trim();
const canSave = mode === 'file' ? !!effectiveSource : isValidUrl(effectiveSource.trim());
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
const handleSave = async () => {
setIsSaving(true);
@@ -84,7 +85,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
toast.error('The selected file is not a valid OpenAPI specification');
toast.error('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const path = window.ipcRenderer.getFilePath(file);

View File

@@ -15,7 +15,7 @@ const DisconnectSyncModal = ({ onConfirm, onClose }) => {
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
</p>
<div className="disconnect-actions">
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="danger" onClick={onConfirm}>

View File

@@ -1,3 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -9,7 +12,6 @@ import {
} from '@tabler/icons';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ActionIcon from 'ui/ActionIcon/index';
import MenuDropdown from 'ui/MenuDropdown';
import Help from 'components/Help';
@@ -23,8 +25,20 @@ const OpenAPISyncHeader = ({
const sourceIsLocal = !isHttpUrl(sourceUrl);
const canCheck = !!sourceUrl?.trim();
const title = spec?.info?.title || 'Unknown API';
const version = spec?.info?.version || '-';
// Resolve relative file paths to absolute for display
const [displayPath, setDisplayPath] = useState(sourceUrl);
useEffect(() => {
if (sourceIsLocal && sourceUrl) {
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
.then((resolved) => setDisplayPath(resolved))
.catch(() => setDisplayPath(sourceUrl));
} else {
setDisplayPath(sourceUrl);
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {
if (!sourceUrl) return;
@@ -111,7 +125,7 @@ const OpenAPISyncHeader = ({
type="button"
onClick={revealInFolder}
>
{sourceUrl}
{displayPath}
</button>
) : (
<a

View File

@@ -1,24 +1,13 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Help from 'components/Help';
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
const countEndpoints = (spec) => {
if (!spec?.paths) return null;
let count = 0;
for (const path of Object.values(spec.paths)) {
for (const key of Object.keys(path)) {
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
}
}
return count;
};
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
const SUMMARY_CARDS = [
@@ -32,7 +21,7 @@ const SUMMARY_CARDS = [
key: 'inSync',
label: 'In Sync with Spec',
color: 'green',
tooltip: 'Endpoints that currently match the latest spec'
tooltip: 'Endpoints that currently match the latest spec from the source'
},
{
key: 'changed',
@@ -50,27 +39,26 @@ const SUMMARY_CARDS = [
}
];
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = storedSpec?.info?.version;
const endpointCount = countEndpoints(storedSpec);
const version = specMeta?.version;
const endpointCount = specMeta?.endpointCount ?? null;
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
const groupBy = openApiSyncConfig?.groupBy || 'tags';
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
// Endpoint Summary counts
// Total/In Sync: always compare against remote spec
// Total: from collection items in Redux; In Sync: from remote spec comparison
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
const totalInCollection = remoteDrift
? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0)
: null;
const totalInCollection = getTotalRequestCountInCollection(collection);
const inSyncCount = remoteDrift
? (remoteDrift.inSync?.length || 0)
@@ -111,6 +99,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const hasSpecUpdates = specUpdatesPending > 0;
const bannerState = useMemo(() => {
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
if (activeError) {
return {
variant: 'danger',
@@ -127,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['review']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes..',
buttons: ['restore']
};
}
if (!hasDriftData) return null;
if (hasSpecUpdates && hasCollectionChanges) {
return {
variant: 'warning',
title: 'The API spec has new updates and the collection has changes',
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
buttons: ['sync', 'changes']
};
@@ -147,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
if (hasSpecUpdates) {
return {
variant: 'warning',
title: 'The API spec has new updates',
title: `OpenAPI spec has new updates${versionInfo}`,
subtitle: 'New or changed requests are available.',
buttons: ['sync']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
buttons: ['spec-details']
};
}
if (!hasDriftData) return null;
if (hasCollectionChanges) {
return {
variant: 'muted',
@@ -160,14 +152,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['changes']
};
}
// return {
// variant: 'success',
// title: 'Collection is in sync with the spec',
// subtitle: null,
// buttons: []
// };
return null;
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
return (
<div className="overview-section">
@@ -179,12 +165,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">{bannerState.title}</span>
{bannerState.showBadge && (
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
)}
{bannerState.showChangesBadge && (
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
)}
</div>
{bannerState.subtitle && (
<p className="banner-subtitle">{bannerState.subtitle}</p>
@@ -207,9 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
Review and Sync Collection
</Button>
)}
{bannerState.buttons.includes('restore') && (
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
Restore Spec File
{bannerState.buttons.includes('spec-details') && (
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
Go to Spec Updates
</Button>
)}
{bannerState.buttons.includes('open-settings') && (

View File

@@ -1,11 +1,13 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
const SpecDiffModal = ({ specDrift, onClose }) => {
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const [isRendering, setIsRendering] = useState(true);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -17,7 +19,11 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
useEffect(() => {
const { Diff2Html } = window;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) return;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
@@ -29,6 +35,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
});
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
return (
@@ -40,8 +47,8 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
>
<div className="spec-diff-modal">
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
@@ -60,7 +67,13 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
<div ref={diffRef}></div>
{isRendering && (
<div className="text-diff-loading">
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
IconCheck,
IconRefresh
IconRefresh,
IconAlertTriangle,
IconClock
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ConfirmSyncModal from '../ConfirmSyncModal';
@@ -23,41 +24,37 @@ const SpecStatusSection = ({
const {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
} = useSyncFlow({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates: onCheck
setError, checkForUpdates: onCheck
});
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
const hasRemoteUpdates = remoteDrift && (
(remoteDrift.missing?.length || 0)
+ (remoteDrift.modified?.length || 0)
+ (remoteDrift.localOnly?.length || 0)
) > 0;
const bannerState = useMemo(() => {
if (fileNotFound) {
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
}
if (error || specDrift?.isValid === false) {
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: [] };
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
}
if (!specDrift) {
return null;
// TODO: re-enable success banner
// if (!lastSyncedAt) return null;
// return {
// variant: 'success', message: 'Spec is up to date', actions: [],
// version: storedSpec?.info?.version,
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
// };
}
if (specDrift.storedSpecMissing) {
if (!lastSyncedAt) {
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
}
if (specDrift.hasRemoteChanges) {
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
}
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
return null;
}
if (specDrift.hasRemoteChanges) {
const hasEndpointUpdates = specDrift.storedSpecMissing
? hasRemoteUpdates
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
if (hasEndpointUpdates) {
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
@@ -66,13 +63,8 @@ const SpecStatusSection = ({
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
};
}
// return {
// variant: 'success', message: 'Spec is up to date', actions: [],
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
// };
return null;
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
return (
<>
{bannerState && (
@@ -94,16 +86,13 @@ const SpecStatusSection = ({
</span>
{bannerState.changes && (
<span className="banner-details">
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
</span>
)}
</div>
<div className="banner-actions">
{bannerState.actions.includes('quick-sync') && (
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
)}
{bannerState.actions.includes('open-settings') && (
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
Update connection settings
@@ -114,16 +103,22 @@ const SpecStatusSection = ({
</div>
)}
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
{(error || fileNotFound || specDrift?.isValid === false) ? (
<div className="sync-review-empty-state mt-5">
<IconRefresh size={40} className="empty-state-icon" />
<h4>Last Synced Spec not found in storage</h4>
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>Unable to check for updates</h4>
<p>Fix the connection issue above and check again.</p>
</div>
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
Restore Spec File
</Button>
</div>
) : remoteDrift && (
) : (
<div className="mt-5">
<SyncReviewPage
specDrift={specDrift}
@@ -133,6 +128,7 @@ const SpecStatusSection = ({
collectionUid={collection.uid}
newSpec={specDrift?.newSpec}
isSyncing={isSyncing}
isLoading={isLoading}
onApplySync={handleApplySync}
/>
</div>

View File

@@ -625,7 +625,7 @@ const StyledWrapper = styled.div`
.settings-label {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.subtext0};
color: ${(props) => props.theme.text};
display: block;
margin-bottom: 5px;
}
@@ -670,7 +670,7 @@ const StyledWrapper = styled.div`
.toggle-description {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
margin-top: 2px;
}
@@ -970,7 +970,7 @@ const StyledWrapper = styled.div`
&.type-local-only { background: ${(props) => props.theme.colors.text.muted}; }
&.type-in-sync { background: ${(props) => props.theme.colors.text.green}; }
&.type-conflict { background: ${(props) => props.theme.colors.text.danger}; }
&.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; }
&.type-spec-modified { background: ${(props) => props.theme.colors.text.warning}; }
&.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; }
}
@@ -988,8 +988,8 @@ const StyledWrapper = styled.div`
height: 1.25rem;
padding: 0 0.3rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.subtext0};
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.subtext1};
background: ${(props) => props.theme.background.surface1};
border-radius: 999px;
}
@@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div`
.disconnect-modal {
.disconnect-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
margin-bottom: 1.5rem;
}
@@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div`
.action-confirm-modal {
.confirm-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
line-height: 1.5;
margin-bottom: 1.5rem;
}
@@ -1504,11 +1503,15 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: hidden;
overflow: auto;
.diff-column-headers {
display: flex;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
.diff-column-label {
flex: 1;
@@ -1640,6 +1643,16 @@ const StyledWrapper = styled.div`
}
}
.text-diff-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.text-diff-empty {
padding: 2rem;
text-align: center;
@@ -1662,8 +1675,9 @@ const StyledWrapper = styled.div`
}
.spec-diff-body {
max-height: calc(80vh - 140px);
overflow: auto;
.text-diff-container {
max-height: calc(80vh - 140px);
}
}
}
@@ -1721,6 +1735,15 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.status.info.text};
background: ${(props) => props.theme.status.info.background};
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.spinner-icon {
animation: spin 1s linear infinite;
}
}
.sync-review-body {
@@ -2190,7 +2213,7 @@ const StyledWrapper = styled.div`
align-self: stretch;
gap: 2px;
padding: 2px;
background: ${(props) => props.theme.background.surface2};
background: ${(props) => props.theme.background.surface1};
border-radius: ${(props) => props.theme.border.radius.md};
}
@@ -2198,7 +2221,7 @@ const StyledWrapper = styled.div`
padding: 0 0.65rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
background: transparent;
border: none;
border-radius: calc(${(props) => props.theme.border.radius.md} - 3px);
@@ -2278,6 +2301,26 @@ const StyledWrapper = styled.div`
gap: 0.5rem;
flex-shrink: 0;
}
.beta-feedback-inline {
margin-top: 2rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
.beta-feedback-link {
background: none;
border: none;
padding: 0;
color: ${(props) => props.theme.status.info.text};
cursor: pointer;
font-size: inherit;
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
}
`;
export default StyledWrapper;

View File

@@ -6,7 +6,7 @@ import {
IconArrowRight,
IconArrowsDiff,
IconInfoCircle,
IconRefresh
IconLoader2
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
* - specRemovedEndpoints: removed from spec, still in collection
*/
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
const specAddedEndpoints = remoteDrift.missing || [];
const specRemovedEndpoints = remoteDrift.localOnly || [];
// Only show endpoints as "New in Spec" if they were actually added to the spec
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
// still exist in both stored and remote spec should not appear here — they belong
// in "Collection Changes" only.
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
// the spec should not appear here — they belong in "Collection Changes" only.
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
// Build lookup sets to determine who changed each modified endpoint
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
@@ -73,6 +83,7 @@ const SyncReviewPage = ({
collectionUid,
newSpec,
isSyncing,
isLoading,
onApplySync
}) => {
const dispatch = useDispatch();
@@ -153,10 +164,7 @@ const SyncReviewPage = ({
// Accepted — changes that will be applied
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
addGroup('Endpoints to update', 'update', [
...specUpdatedEndpoints.filter(isAccepted),
...localUpdatedEndpoints.filter(isAccepted)
]);
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
// Skipped — changes that will be preserved as-is
@@ -166,7 +174,7 @@ const SyncReviewPage = ({
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
return groups;
}, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
const handleConfirmApply = () => {
setShowConfirmation(false);
@@ -186,7 +194,6 @@ const SyncReviewPage = ({
onApplySync({
endpointDecisions: decisions,
removedIds: [],
localOnlyIds,
// Pass filtered categorized endpoints for performSync to construct the right backend diff
newToCollection: filteredAddedEndpoints,
@@ -250,9 +257,19 @@ const SyncReviewPage = ({
<div className="sync-review-body">
{!hasRemoteUpdates ? (
<div className="sync-review-empty-state">
<IconRefresh size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The collection matches the latest spec. Nothing to sync.</p>
{isLoading ? (
<>
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your last synced spec with the latest spec...</p>
</>
) : (
<>
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync.</p>
</>
)}
</div>
) : (
<div className="endpoints-review-sections">
@@ -264,7 +281,7 @@ const SyncReviewPage = ({
title="Updated in Spec"
type="spec-modified"
endpoints={specUpdatedEndpoints}
defaultExpanded={hasConflicts}
defaultExpanded={true}
expandableLayout
subtitle="The spec has updates for these endpoints"
headerExtra={conflictCount > 0 ? (
@@ -300,7 +317,7 @@ const SyncReviewPage = ({
title="New in Spec"
type="added"
endpoints={specAddedEndpoints}
defaultExpanded={false}
defaultExpanded={true}
expandableLayout
subtitle="New endpoints from the spec"
collectionUid={collectionUid}
@@ -324,7 +341,7 @@ const SyncReviewPage = ({
title="Removed from Spec"
type="removed"
endpoints={specRemovedEndpoints}
defaultExpanded={false}
defaultExpanded={true}
expandableLayout
subtitle="These endpoints are in your collection but not in the spec"
collectionUid={collectionUid}

View File

@@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
import { formatIpcError } from 'utils/common/error';
import { countEndpoints } from '../utils';
const useOpenAPISync = (collection) => {
const dispatch = useDispatch();
@@ -29,6 +30,16 @@ const useOpenAPISync = (collection) => {
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
version: spec?.info?.version || null,
endpointCount: spec ? countEndpoints(spec) : null
}));
};
// Flatten collection items including nested items in folders
const allHttpItems = useMemo(() => {
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
@@ -77,6 +88,7 @@ const useOpenAPISync = (collection) => {
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
@@ -86,8 +98,7 @@ const useOpenAPISync = (collection) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig
collectionPath: collection.pathname
});
if (!result.error) {
@@ -113,6 +124,7 @@ const useOpenAPISync = (collection) => {
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
try {
const { ipcRenderer } = window;
@@ -135,9 +147,7 @@ const useOpenAPISync = (collection) => {
}
setSpecDrift(result);
if (result.storedSpec) {
setStoredSpec(result.storedSpec);
}
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
@@ -151,7 +161,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
if (remoteComparison.error) {
@@ -211,11 +220,11 @@ const useOpenAPISync = (collection) => {
try {
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
if (specType !== 'openapi') {
setError('The URL does not point to a valid OpenAPI specification');
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
} catch {
setError('The URL does not point to a valid OpenAPI specification');
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
}
@@ -256,7 +265,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
@@ -269,8 +277,7 @@ const useOpenAPISync = (collection) => {
// Collection matches — save spec file silently to complete setup
await ipcRenderer.invoke('renderer:save-openapi-spec', {
collectionPath: collection.pathname,
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
sourceUrl: trimmedUrl
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
});
}
}
@@ -289,7 +296,6 @@ const useOpenAPISync = (collection) => {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
collectionPath: collection.pathname,
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
deleteSpecFile: true
});
setSourceUrl('');
@@ -314,8 +320,30 @@ const useOpenAPISync = (collection) => {
}
};
// Reload drift — passed to useEndpointActions so it can refresh after actions
const reloadDrift = () => loadCollectionDrift({ clear: true });
// Keep ref in sync so reloadDrift always reads the latest specDrift
specDriftRef.current = specDrift;
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
// Uses specDriftRef to avoid stale closure over specDrift state.
const reloadDrift = async () => {
await loadCollectionDrift({ clear: true });
// Refresh remoteDrift if we have a remote spec cached from the last check
const currentSpecDrift = specDriftRef.current;
if (currentSpecDrift?.newSpec) {
try {
const { ipcRenderer } = window;
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
setRemoteDrift(remoteComparison);
}
} catch (err) {
console.error('Error reloading remote drift:', err);
}
}
};
// Save connection settings from the modal
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
@@ -328,11 +356,11 @@ const useOpenAPISync = (collection) => {
try {
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
} catch {
toast.error('The URL does not point to a valid OpenAPI specification');
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
if (specType !== 'openapi') {
toast.error('The URL does not point to a valid OpenAPI specification');
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
}
@@ -342,7 +370,6 @@ const useOpenAPISync = (collection) => {
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
oldSourceUrl: openApiSyncConfig?.sourceUrl,
config: {
sourceUrl: newUrl,
autoCheck,

View File

@@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error';
const useSyncFlow = ({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates
setError, checkForUpdates
}) => {
const dispatch = useDispatch();
@@ -14,13 +14,13 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
const {
removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
localOnlyIds = [], endpointDecisions: decisions = {},
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
} = selections;
@@ -49,9 +49,7 @@ const useSyncFlow = ({
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
filteredDiff = {
...specDrift,
removed: removedIds.length > 0
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
: []
removed: [] // Removals handled via localOnlyToRemove
};
localOnlyToRemove = localOnlyIds.length > 0
@@ -67,9 +65,8 @@ const useSyncFlow = ({
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: sourceUrl.trim(),
addNewRequests: mode !== 'spec-only',
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
removeDeletedRequests: localOnlyIds.length > 0,
diff: filteredDiff,
localOnlyToRemove,
driftedToReset,
@@ -113,10 +110,28 @@ const useSyncFlow = ({
setPendingSyncMode(null);
};
// Only treat endpoints as spec changes if they actually changed in the spec
// (not locally-added/deleted endpoints that were never in or removed from the spec)
const specAddedIds = useMemo(() => {
return new Set((specDrift?.added || []).map((ep) => ep.id));
}, [specDrift]);
const specRemovedIds = useMemo(() => {
return new Set((specDrift?.removed || []).map((ep) => ep.id));
}, [specDrift]);
const handleRestoreSpec = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
};
const handleConfirmModalSync = () => {
const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({
removedIds: [],
localOnlyIds,
endpointDecisions: {}
}, pendingSyncMode || 'sync');
@@ -125,21 +140,23 @@ const useSyncFlow = ({
const confirmGroups = useMemo(() => {
if (!remoteDrift) return [];
const groups = [];
if (remoteDrift.missing?.length > 0) {
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
if (actuallyAdded.length > 0) {
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
}
if (remoteDrift.modified?.length > 0) {
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
}
if (remoteDrift.localOnly?.length > 0) {
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
if (actuallyRemoved.length > 0) {
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
}
return groups;
}, [remoteDrift]);
}, [remoteDrift, specAddedIds, specRemovedIds]);
return {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow,
handleSyncNow, handleRestoreSpec,
handleApplySync, cancelConfirmModal, handleConfirmModalSync
};
};

View File

@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuid } from 'uuid';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
import { IconClock } from '@tabler/icons';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
import OpenAPISyncHeader from './OpenAPISyncHeader';
@@ -130,48 +129,35 @@ const OpenAPISyncTab = ({ collection }) => {
remoteDrift={remoteDrift}
onTabSelect={setActiveTab}
error={error}
isLoading={isLoading}
fileNotFound={fileNotFound}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
)}
{activeTab === 'collection-changes' && (
<div className="sync-tab-content">
{collectionDrift && !collectionDrift.noStoredSpec ? (
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
/>
) : !isDriftLoading && !isLoading && (
<>
<div className="spec-update-banner warning">
<div className="banner-left">
<div className="status-dot warning" />
<span className="banner-title">
{openApiSyncConfig?.lastSyncDate
? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
: 'Collection changes will be available after the initial sync'}
</span>
</div>
</div>
<div className="sync-review-empty-state mt-5">
<IconClock size={40} className="empty-state-icon" />
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
<p>{openApiSyncConfig?.lastSyncDate
? 'Restore the latest spec from the source to track future changes..'
: 'Once you sync your collection with the spec, changes will appear here.'}
</p>
</div>
</>
)}
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
isLoading={isDriftLoading || isLoading}
onTabSelect={setActiveTab}
/>
</div>
)}
@@ -195,6 +181,7 @@ const OpenAPISyncTab = ({ collection }) => {
)}
</>
)}
</div>
{showSettingsModal && (

View File

@@ -0,0 +1,16 @@
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
/**
* Count the number of HTTP endpoints in an OpenAPI spec.
* Returns null if the spec has no paths (e.g. spec is null/undefined).
*/
export const countEndpoints = (spec) => {
if (!spec?.paths) return null;
let count = 0;
for (const path of Object.values(spec.paths)) {
for (const key of Object.keys(path)) {
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
}
}
return count;
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
/**
* Add beta features here.
* Example:
* {
* id: 'nodevm',
* label: 'Node VM Runtime',
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
* }
* UI metadata for beta features rendered in Preferences.
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
*/
const BETA_FEATURES = [];
const BETA_FEATURES = [
{
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
label: 'OpenAPI Sync',
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
}
];
const Beta = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
const betaSchema = generateValidationSchema();
const formik = useFormik({
enableReinitialize: true,
initialValues: generateInitialValues(),
validationSchema: betaSchema,
onSubmit: async (values) => {
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
beta: {
...preferences.beta,
...newBetaPreferences
}
})
)
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
betaSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave, betaSchema]
[betaSchema]
);
// Auto-save when form values change
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -68,7 +68,7 @@ const Cache = () => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -38,11 +38,14 @@ const Font = () => {
});
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((font, fontSize) => {
handleSave(font, fontSize);
handleSaveRef.current(font, fontSize);
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -52,7 +55,7 @@ const Font = () => {
}
debouncedSave(codeFont, codeFontSize);
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [codeFont, codeFontSize, debouncedSave]);

View File

@@ -127,16 +127,19 @@ const General = () => {
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
preferencesSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -144,7 +147,7 @@ const General = () => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -1,199 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
min-height: 0;
max-height: calc(100% - 30px);
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 80%;
border-collapse: collapse;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.reset-all-btn {
display: flex;
align-items: center;
background: transparent;
border: 1px solid ${(props) => props.theme.table.border};
border-radius: 6px;
padding: 4px 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.button.secondary.hoverBg};
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
}
}
.keybinding-row {
display: flex;
align-items: center;
gap: 10px;
}
.keybinding-row .edit-btn,
.keybinding-row .reset-btn {
flex-shrink: 0;
}
.button-placeholder {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keybinding-row:hover .edit-btn {
opacity: 0.9;
}
.shortcut-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 260px;
flex: 1;
}
.shortcut-input {
width: 200px;
max-width: 200px;
flex-shrink: 0;
caret-color: ${(props) => props.theme.table.input.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
background: transparent;
font-family: monospace;
color: ${(props) => props.theme.table.input.color};
cursor: pointer;
&:hover {
opacity: 0.85;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
&:focus {
opacity: 1;
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
&::placeholder {
opacity: 0.5;
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
}
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
.reset-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
border-radius: 8px;
padding: 0px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.shortcut-input--error {
opacity: 1;
}
.tooltip-mod.tooltip-mod--error{
color: ${(props) => props.theme.status.danger.text} !important;
}
.table-container {
margin-bottom: 24px;
min-height: 0;
overflow-y: auto;
border-radius: 8px;
border-top: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
&::-webkit-scrollbar {
width: 0;
height: 0;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
thead th:first-child,
tbody td:first-child {
width: 35%;
}
thead th:last-child,
tbody td:last-child {
width: 45%;
}
thead th {
position: sticky;
top: 0;
z-index: 5;
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
font-weight: 500;
padding: 10px;
text-align: left;
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
border-top: 1px solid ${(props) => props.theme.table.border};
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
.key-button {
display: inline-block;
color: ${(props) => props.theme.table.input.color};
opacity: 0.7;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
margin-right: 8px;
border: 1px solid #ccc;
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
}
`;

View File

@@ -1,516 +1,14 @@
import React, { useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { IconReload, IconPencil } from '@tabler/icons';
import React from 'react';
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
import { isMacOS } from 'utils/common/platform';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
// Stored tokens must match your preferences defaults (lowercase)
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
const REQUIRED_MODIFIERS_BY_OS = {
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
};
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
const sortCombo = (arr) => {
const order = ['ctrl', 'command', 'alt', 'shift'];
const modifiers = [];
const nonModifiers = [];
// Separate modifiers from non-modifiers
arr.forEach((key) => {
if (order.includes(key)) {
modifiers.push(key);
} else {
nonModifiers.push(key);
}
});
// Sort modifiers by their order
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
// Keep non-modifiers in the order they were pressed (don't sort them)
return [...modifiers, ...nonModifiers];
};
const uniqSorted = (arr) => {
// Remove duplicates while preserving order
const unique = [];
const seen = new Set();
arr.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
});
return sortCombo(unique);
};
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
// Signature MUST be stable: unique + sorted
const comboSignature = (arr) => toKeysString(arr);
// OS reserved shortcuts in stored-token format
const RESERVED_BY_OS = {
mac: new Set([
comboSignature(['command', 'q']),
comboSignature(['command', 'w']),
comboSignature(['command', 'h']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['command', 'space']),
comboSignature(['ctrl', 'command', 'q']),
comboSignature(['command', ',']),
comboSignature(['command', 'shift', '3']),
comboSignature(['command', 'shift', '4']),
comboSignature(['command', 'shift', '5']),
comboSignature(['command', 'alt', 'esc'])
]),
windows: new Set([
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc'])
])
};
// normalize keyboard event -> stored tokens
const normalizeKey = (e) => {
const k = e.key;
// ignore lock keys
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
if (k === ' ') return 'space';
if (k === 'Escape') return 'esc';
if (k === 'Control') return 'ctrl';
if (k === 'Alt') return 'alt';
if (k === 'Shift') return 'shift';
if (k === 'Enter') return 'enter';
if (k === 'Backspace') return 'backspace';
if (k === 'Tab') return 'tab';
if (k === 'Delete') return 'delete';
// Meta -> command (matches your stored default format)
if (k === 'Meta') return 'command';
// single char (letters/punct) to lowercase
if (k.length === 1) return k.toLowerCase();
// ArrowUp -> arrowup, PageUp -> pageup, etc
return k.toLowerCase();
};
const ERROR = {
EMPTY: 'EMPTY',
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
RESERVED: 'RESERVED',
DUPLICATE: 'DUPLICATE',
CONFLICT: 'CONFLICT'
};
const Keybindings = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const os = getOS();
// Source of truth: merge defaults with user preferences
const keyBindings = useMemo(() => {
const merged = {};
// Start with defaults
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
merged[action] = { ...binding };
}
// Override with user preferences
const userBindings = preferences?.keyBindings || {};
for (const [action, binding] of Object.entries(userBindings)) {
if (merged[action]) {
// Merge user's OS-specific overrides into defaults
merged[action] = {
...merged[action],
...binding
};
}
}
return merged;
}, [preferences?.keyBindings]);
// Build table data (action -> { name, keys })
const keyMapping = useMemo(() => {
const out = {};
for (const [action, binding] of Object.entries(keyBindings)) {
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
}
return out;
}, [keyBindings, os]);
// ✏️ which row is allowed to edit (pencil clicked)
const [editingAction, setEditingAction] = useState(null);
// hover tracking (for showing pencil/reset only on hover row)
const [hoveredAction, setHoveredAction] = useState(null);
// Recording state
const [recordingAction, setRecordingAction] = useState(null);
const pressedKeysRef = useRef(new Set());
const inputRefs = useRef({});
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
const isRowDirty = (action) => {
const current = getCurrentRowKeysString(action);
const def = getDefaultRowKeysString(action);
if (!DEFAULT_KEY_BINDINGS) return false;
return current !== def;
};
// Check if any keybinding is dirty (different from default)
const hasDirtyRows = useMemo(() => {
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
if (isRowDirty(action)) {
return true;
}
}
return false;
}, [keyBindings, os]);
const buildUsedSignatures = (excludeAction) => {
const used = new Set();
for (const [action, binding] of Object.entries(keyBindings)) {
if (action === excludeAction) continue;
const keysStr = binding?.[os];
if (!keysStr) continue;
used.add(comboSignature(fromKeysString(keysStr)));
}
return used;
};
const validateCombo = (action, arrRaw) => {
const arr = uniqSorted(arrRaw);
const sig = comboSignature(arr);
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut cant be empty.` };
if (isOnlyModifiers(arr))
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
// OS-specific must-have modifier rule
if (!hasRequiredModifier(os, arr)) {
return {
code: ERROR.MISSING_REQUIRED_MOD,
message:
os === 'mac'
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
};
}
// OS reserved
if (RESERVED_BY_OS[os]?.has(sig))
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
// No duplicates (across all other actions)
if (buildUsedSignatures(action).has(sig))
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
for (const [otherAction, binding] of Object.entries(keyBindings)) {
if (otherAction === action) continue;
const otherKeysStr = binding?.[os];
if (!otherKeysStr) continue;
const otherKeys = fromKeysString(otherKeysStr);
// Check if current is a subset of other (current is shorter)
if (arr.length < otherKeys.length) {
const isSubset = arr.every((k) => otherKeys.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
};
}
}
// Check if other is a subset of current (current is longer)
if (arr.length > otherKeys.length) {
const isSubset = otherKeys.every((k) => arr.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
};
}
}
}
return null;
};
const persistToPreferences = (action, nextKeys) => {
const updatedPreferences = {
...preferences,
keyBindings: {
...(preferences?.keyBindings || {}),
[action]: {
...(preferences?.keyBindings?.[action] || {}),
name: preferences?.keyBindings?.[action]?.name || DEFAULT_KEY_BINDINGS?.[action]?.name || action,
[os]: nextKeys
}
}
};
dispatch(savePreferences(updatedPreferences));
};
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
const commitCombo = (action) => {
const draftArr = draftByAction[action] || [];
if (!draftArr.length) return;
const arr = uniqSorted(draftArr);
const err = validateCombo(action, arr);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
return false;
}
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
const nextKeys = toKeysString(arr);
const currentKeys = getCurrentRowKeysString(action);
if (nextKeys === currentKeys) return true;
persistToPreferences(action, nextKeys);
// toast success for 2s with Command name
const commandName = keyBindings?.[action]?.name || action;
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
return true;
};
const resetRowToDefault = (action) => {
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
if (!def) return;
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
persistToPreferences(action, def);
};
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
keyBindings: {}
};
dispatch(savePreferences(updatedPreferences));
};
const startEditing = (action) => {
// if another row is editing, commit/stop it first
if (editingAction && editingAction !== action) {
const ok = commitCombo(editingAction);
if (ok) {
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
} else {
// keep previous row editing if invalid
return;
}
}
setEditingAction(action);
setRecordingAction(action);
pressedKeysRef.current = new Set();
// seed draft with current value
setDraftByAction((prev) => ({
...prev,
[action]: fromKeysString(getCurrentRowKeysString(action))
}));
// clear error on start edit
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
requestAnimationFrame(() => {
inputRefs.current[action]?.focus?.();
inputRefs.current[action]?.setSelectionRange?.(
inputRefs.current[action].value.length,
inputRefs.current[action].value.length
);
});
};
const stopEditing = (action) => {
const ok = commitCombo(action);
if (!ok) {
// If commit failed (validation error), reset to original value
cancelEditing(action);
return;
}
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
// Reset draft to original value and clear error (used on blur with invalid state)
const cancelEditing = (action) => {
// Clear error for this action
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
// Reset draft to current saved value
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
const handleKeyDown = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
// allow user to clear and keep editing (do NOT auto-stop)
if (e.key === 'Backspace' || e.key === 'Delete') {
pressedKeysRef.current = new Set();
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
setErrorByAction((prev) => ({
...prev,
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
}));
return;
}
if (e.repeat) return;
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.add(keyName);
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
setDraftByAction((prev) => ({
...prev,
[action]: currentDraft
}));
const err = validateCombo(action, currentDraft);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
} else {
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
}
};
const handleKeyUp = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.delete(keyName);
// commit only when released AND currently valid
if (pressedKeysRef.current.size === 0) {
const currentDraft = draftByAction[action] || [];
// if empty -> keep editing
if (currentDraft.length === 0) return;
// if error -> keep editing
if (errorByAction[action]?.message) return;
stopEditing(action);
}
};
const renderValue = (action) => {
const arr
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
return (arr || []).join(' + ');
};
const Keybindings = ({ close }) => {
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
return (
<StyledWrapper className="w-full">
<div className="section-header">
<span>Keybindings</span>
{hasDirtyRows && (
<button
type="button"
className="reset-all-btn"
onClick={resetAllKeybindings}
title="Reset all keybindings to default"
>
<IconReload size={14} stroke={1} />
</button>
)}
</div>
<div className="section-header">Keybindings</div>
<div className="table-container">
<table>
<thead>
@@ -521,89 +19,18 @@ const Keybindings = () => {
</thead>
<tbody>
{keyMapping ? (
Object.entries(keyMapping).map(([action, row]) => {
const isEditing = editingAction === action;
const isHovered = hoveredAction === action;
const isDirty = isRowDirty(action);
const showPencil = isHovered && !isEditing && !isDirty;
const showReset = isDirty && !isEditing;
const hasError = Boolean(errorByAction[action]?.message);
const errorMessage = errorByAction[action]?.message;
const inputId = `kb-input-${action}`;
return (
<tr
key={action}
data-testid={`keybinding-row-${action}`}
onMouseEnter={() => setHoveredAction(action)}
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
>
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
<td>
<div className="keybinding-row">
<div className="shortcut-wrap">
<input
id={inputId}
ref={(el) => {
if (el) inputRefs.current[action] = el;
}}
data-testid={`keybinding-input-${action}`}
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
value={renderValue(action)}
readOnly={!isEditing}
onKeyDown={(e) => handleKeyDown(action, e)}
onKeyUp={(e) => { handleKeyUp(action, e); }}
onBlur={() => {
// If there's an error, reset to original value instead of keeping invalid state
if (isEditing && hasError) {
cancelEditing(action);
} else if (isEditing) {
stopEditing(action);
}
}}
spellCheck={false}
/>
{isEditing && hasError && (
<Tooltip
id={`kb-editing-error-tooltip-${action}`}
anchorSelect={`#${inputId}`}
place="bottom-start"
opacity={1}
isOpen={true}
content={errorMessage}
className="tooltip-mod tooltip-mod--error"
/>
)}
</div>
{showReset ? (
<button
type="button"
className="reset-btn"
data-testid={`keybinding-reset-${action}`}
onClick={() => resetRowToDefault(action)}
title="Reset to default"
>
<IconReload size={14} stroke={1} />
</button>
) : null}
{showPencil ? (
<button
type="button"
className="edit-btn"
data-testid={`keybinding-edit-${action}`}
onClick={() => startEditing(action)}
title="Edit shortcut"
>
<IconPencil size={14} stroke={1.5} />
</button>
) : null}
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
<tr key={index}>
<td>{name}</td>
<td>
{keys.split('+').map((key, i) => (
<div className="key-button" key={i}>
{key}
</div>
</td>
</tr>
);
})
))}
</td>
</tr>
))
) : (
<tr>
<td colSpan="2">No key bindings available</td>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
@@ -75,41 +75,26 @@ const ProxySettings = ({ close }) => {
});
}, [dispatch, preferences, proxySchema]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const debouncedSave = useCallback(
debounce((values) => {
onUpdate(values);
onUpdateRef.current(values);
}, 500),
[onUpdate]
[]
);
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
port: preferences.proxy.config?.port || '',
auth: {
disabled: preferences.proxy.config?.auth?.disabled || false,
username: preferences.proxy.config?.auth?.username || '',
password: preferences.proxy.config?.auth?.password || ''
},
bypassProxy: preferences.proxy.config?.bypassProxy || ''
}
});
}, [preferences]);
useEffect(() => {
if (formik.dirty) {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, debouncedSave]);
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
return (
<StyledWrapper>

View File

@@ -3,7 +3,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
padding: 12px;
min-width: 180px;
min-width: 160px;
div.tab {
display: flex;
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
}
section.tab-panel {
max-height: calc(100% - 24px);
min-height: 70vh;
overflow-y: auto;
flex-grow: 1;
padding: 12px;

View File

@@ -33,7 +33,7 @@ const RequestNotFound = ({ itemUid }) => {
const errors = [
{
title: 'Request no longer exists',
message: 'This can happen when the .bru file associated with this request was deleted on your filesystem.'
message: 'This can happen when the file associated with this request was deleted on your filesystem.'
}
];

View File

@@ -177,7 +177,7 @@ const RequestTabPanel = () => {
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
return <div className="pb-4 px-4">Loading...</div>;
}
if (focusedTab.type === 'global-environment-settings') {

View File

@@ -72,19 +72,46 @@ const StyledWrapper = styled.div`
padding: 4px 8px;
}
.workspace-input-wrapper {
display: flex;
align-items: center;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
min-width: 150px;
&:focus-within {
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.workspace-name-input {
font-size: 14px;
font-weight: 500;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
border: none;
background: transparent;
color: ${(props) => props.theme.text};
outline: none;
min-width: 150px;
flex: 1;
min-width: 0;
}
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
.cog-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 22px;
height: 100%;
border: none;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
opacity: 0.5;
&:hover {
opacity: 1;
}
}

View File

@@ -15,7 +15,7 @@ import {
IconUpload
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
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 { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
@@ -24,14 +24,18 @@ import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import ToolHint from 'components/ToolHint';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { normalizePath } from 'utils/common/path';
import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
import StatusBadge from 'ui/StatusBadge/index';
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
@@ -42,17 +46,25 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const gitRootPath = collection?.git?.gitRootPath;
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
// Workspace rename state
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const switcherRef = useRef();
const workspaceActionsRef = useRef();
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const openingAdvancedRef = useRef(false);
const clickedOutsideRef = useRef(false);
const handleSaveRef = useRef(null);
const tempWorkspaceUidRef = useRef(null);
const isSavingRef = useRef(false);
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
@@ -68,17 +80,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
const handleCancelWorkspaceRename = useCallback(() => {
if (openingAdvancedRef.current) return;
if (currentWorkspace?.isCreating) {
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
return;
}
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
}, []);
}, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
if (currentWorkspace?.isCreating) {
clickedOutsideRef.current = true;
handleSaveRef.current?.();
} else {
handleCancelWorkspaceRename();
}
}
};
@@ -91,7 +113,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
document.removeEventListener('mousedown', handleClickOutside);
clearTimeout(timer);
};
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
}, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]);
const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {});
const { theme } = useTheme();
@@ -112,7 +134,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
if (isScratch) return false;
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath));
});
// Count tabs for the current collection
@@ -201,8 +223,8 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
// Build overflow menu items for the "..." dropdown
const overflowMenuItems = [
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
...(!hasOpenApiSyncConfigured
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>, onClick: viewOpenApiSync }]
: []),
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings }
];
@@ -262,28 +284,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
};
const handleSaveWorkspaceRename = () => {
const fromOutside = clickedOutsideRef.current;
clickedOutsideRef.current = false;
if (openingAdvancedRef.current) return;
if (isSavingRef.current) return;
const trimmedName = workspaceNameInput?.trim();
if (!trimmedName) {
if (fromOutside && currentWorkspace?.isCreating) {
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
return;
}
setWorkspaceNameError('Name is required');
return;
}
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
if (fromOutside && currentWorkspace?.isCreating) {
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
}
return;
}
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
isSavingRef.current = true;
if (currentWorkspace?.isCreating) {
dispatch(confirmWorkspaceCreation(uid, trimmedName))
.then(() => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
toast.success('Workspace created!');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while creating the workspace');
})
.finally(() => {
isSavingRef.current = false;
});
} else {
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
})
.finally(() => {
isSavingRef.current = false;
});
}
};
// Keep ref in sync so click-outside handler always has the latest save logic
handleSaveRef.current = handleSaveWorkspaceRename;
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
@@ -301,6 +366,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
}
};
const handleOpenAdvancedCreate = () => {
openingAdvancedRef.current = true;
tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null;
setCreateWorkspaceModalOpen(true);
};
const handleAdvancedCreateClose = () => {
openingAdvancedRef.current = false;
setCreateWorkspaceModalOpen(false);
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
const tempUid = tempWorkspaceUidRef.current;
tempWorkspaceUidRef.current = null;
// Clean up the temp workspace (cancelWorkspaceCreation only switches to default
// if the temp workspace was still active, so this is safe after modal success too)
if (tempUid) {
dispatch(cancelWorkspaceCreation(tempUid));
}
};
// Check if workspace actions should be shown
const showWorkspaceActions = isScratchCollection
&& currentWorkspace
@@ -316,30 +402,46 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
/>
)}
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={handleAdvancedCreateClose} />
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="workspace-input-wrapper">
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{currentWorkspace?.isCreating && (
<button
className="cog-btn"
onMouseDown={(e) => e.preventDefault()}
onClick={handleOpenAdvancedCreate}
title="Advanced options"
>
<IconSettings size={13} strokeWidth={1.5} />
</button>
)}
</div>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
title={currentWorkspace?.isCreating ? 'Create' : 'Save'}
>
<IconCheck size={14} strokeWidth={2} />
</button>
@@ -461,8 +563,8 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1.5 items-center justify-end">
{/* OpenAPI Sync - standalone only when configured */}
{hasOpenApiSyncConfigured && (
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
{isOpenAPISyncEnabled && hasOpenApiSyncConfigured && (
<ToolHint
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
toolhintId="OpenApiSyncToolhintId"

View File

@@ -2,6 +2,7 @@ import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import StatusBadge from 'ui/StatusBadge/index';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -90,7 +91,8 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
return (
<>
<OpenAPISyncIcon size={14} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">OpenAPI</span>
<span className="ml-1 tab-name mr-1">OpenAPI</span>
<StatusBadge status="info" size="xs">Beta</StatusBadge>
</>
);
}

View File

@@ -36,10 +36,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeTab = tabs.find((t) => t.uid === activeTabUid);
const menuDropdownRef = useRef();
const item = findItemInCollection(collection, tab.uid);
@@ -90,62 +86,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
}, [item, item?.name, method, setHasOverflow]);
useEffect(() => {
const handleCloseTabFromHotkeys = () => {
if (!activeTabUid || !activeTab) return;
// Only the active tab component should handle this
if (tab.uid !== activeTabUid) return;
// Always compute item for the active tab
const activeItem = findItemInCollection(collection, activeTabUid);
switch (activeTab.type) {
case 'request':
if (activeItem && hasRequestChanges(activeItem)) {
console.log('Item have changes');
setShowConfirmClose(true);
} else {
console.log('Item dont have changes');
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'collection-settings':
if (collection?.draft) {
setShowConfirmCollectionClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'folder-settings': {
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
if (folderItem?.draft) {
setShowConfirmFolderClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
}
case 'environment-settings':
if (collection?.environmentsDraft) {
setShowConfirmEnvironmentClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
default:
break;
}
};
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();

View File

@@ -17,7 +17,7 @@ import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { resolveRequestFilename } from 'utils/common/platform';
import path from 'utils/common/path';
import path, { normalizePath } from 'utils/common/path';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
@@ -50,7 +50,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
if (!isScratchCollection || !activeWorkspace) return [];
return (activeWorkspace.collections || []).map((wc) => {
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
const fullCollection = allCollections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path));
// Use stable deterministic UID based on path to avoid duplicate Redux entries
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import path from 'path';
import path from 'utils/common/path';
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import { isElectron } from 'utils/common/platform';

View File

@@ -10,7 +10,7 @@ import {
} from 'providers/ReduxStore/slices/collections/actions';
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
import Modal from 'components/Modal';
import * as path from 'path';
import path from 'utils/common/path';
import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import path from 'path';
import path from 'utils/common/path';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';

View File

@@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
<Button type="submit" data-testid="collection-item-clone">
<Button type="submit">
Clone
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
@@ -69,21 +69,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const preferences = useSelector((state) => state.app.preferences);
const userKeyBindings = preferences?.keyBindings || {};
const hasCustomCopyBinding = !!userKeyBindings?.copyItem;
const hasCustomPasteBinding = !!userKeyBindings?.pasteItem;
const hasCustomRenameBinding = !!userKeyBindings?.renameItem;
const dispatch = useDispatch();
// We use a single ref for drag and drop.
const ref = useRef(null);
const menuDropdownRef = useRef(null);
// Refs to store current handler references for event listeners (avoid stale closures)
const copyHandlerRef = useRef(null);
const pasteHandlerRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
@@ -130,52 +121,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
}, [isTabForItemActive]);
// Listen for clone-item-open event from Hotkeys provider
const isFocusedRef = useRef(isKeyboardFocused);
isFocusedRef.current = isKeyboardFocused;
useEffect(() => {
const handleCloneItemOpen = () => {
// Only open modal if this item is keyboard focused
if (isFocusedRef.current) {
setCloneItemModalOpen(true);
}
};
const handleCopyItemOpen = () => {
// Copy item to clipboard if this item is keyboard focused
if (isFocusedRef.current && copyHandlerRef.current) {
copyHandlerRef.current();
}
};
const handlePasteItemOpen = () => {
// Paste item from clipboard if this item is keyboard focused
if (isFocusedRef.current && pasteHandlerRef.current) {
pasteHandlerRef.current();
}
};
const handleRenameItemOpen = () => {
// Rename item if this item is keyboard focused
if (isFocusedRef.current) {
setRenameItemModalOpen(true);
}
};
window.addEventListener('clone-item-open', handleCloneItemOpen);
window.addEventListener('copy-item-open', handleCopyItemOpen);
window.addEventListener('paste-item-open', handlePasteItemOpen);
window.addEventListener('rename-item-open', handleRenameItemOpen);
return () => {
window.removeEventListener('clone-item-open', handleCloneItemOpen);
window.removeEventListener('copy-item-open', handleCopyItemOpen);
window.removeEventListener('paste-item-open', handlePasteItemOpen);
window.removeEventListener('rename-item-open', handleRenameItemOpen);
};
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
@@ -483,33 +428,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
return items;
};
const handleCopyItem = useCallback(() => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied`);
}, [dispatch, item, isFolder]);
const handlePasteItem = useCallback(() => {
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
const parentFolder = findParentItemInCollection(collection, item.uid);
targetFolderUid = parentFolder ? parentFolder.uid : null;
}
dispatch(pasteItem(collectionUid, targetFolderUid))
.then(() => {
toast.success('Item pasted successfully');
})
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while pasting the item');
});
}, [dispatch, collection, item, isFolder, collectionUid]);
// Update refs whenever handlers change
copyHandlerRef.current = handleCopyItem;
pasteHandlerRef.current = handlePasteItem;
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
});
@@ -619,25 +537,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
};
const handleCopyItem = () => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied`);
};
const handlePasteItem = () => {
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
const parentFolder = findParentItemInCollection(collection, item.uid);
targetFolderUid = parentFolder ? parentFolder.uid : null;
}
dispatch(pasteItem(collectionUid, targetFolderUid))
.then(() => {
toast.success('Item pasted successfully');
})
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while pasting the item');
});
};
// Keyboard shortcuts handler
const handleKeyDown = (e) => {
// Detect Mac by checking both metaKey and platform
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
// Only use default handler if no custom keybinding is set for copy/paste
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
if (copyHandlerRef.current) copyHandlerRef.current();
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (pasteHandlerRef.current) pasteHandlerRef.current();
} else if (!hasCustomRenameBinding && e.key === 'F2') {
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
const renameKey = isMac ? macRenameKey : winRenameKey;
// Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request)
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
if (e.key.toLowerCase() === renameKey && !hasModifier) {
e.preventDefault();
e.stopPropagation();
setRenameItemModalOpen(true);
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
handleCopyItem();
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
handlePasteItem();
}
};

View File

@@ -48,6 +48,8 @@ import { getRevealInFolderLabel } from 'utils/common/platform';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
import StatusBadge from 'ui/StatusBadge';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
@@ -56,6 +58,7 @@ import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
const EMPTY_STATE_DELAY_MS = 300;
const Collection = ({ collection, searchText }) => {
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
const { dropdownContainerRef } = useSidebarAccordion();
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
@@ -278,34 +281,6 @@ const Collection = ({ collection, searchText }) => {
}
}, [isCollectionFocused]);
// Listen for clone-item-open event from Hotkeys provider
const isFocusedRef = useRef(isKeyboardFocused);
isFocusedRef.current = isKeyboardFocused;
useEffect(() => {
const handleCloneItemOpen = () => {
// Only open modal if this collection is keyboard focused
if (isFocusedRef.current) {
setShowCloneCollectionModalOpen(true);
}
};
const handleRenameCollectionOpen = () => {
// Only open rename collection modal if this collection is keyboard focused
if (isFocusedRef.current) {
setShowRenameCollectionModal(true);
}
};
window.addEventListener('clone-item-open', handleCloneItemOpen);
window.addEventListener('rename-item-open', handleRenameCollectionOpen);
return () => {
window.removeEventListener('clone-item-open', handleCloneItemOpen);
window.removeEventListener('rename-item-open', handleRenameCollectionOpen);
};
}, []);
// Debounce showing empty state to prevent flicker
// Race condition: isLoading can become false before items batch arrives from IPC
useEffect(() => {
@@ -382,12 +357,13 @@ const Collection = ({ collection, searchText }) => {
setShowCloneCollectionModalOpen(true);
}
},
{
...(isOpenAPISyncEnabled ? [{
id: 'sync-openapi',
leftSection: OpenAPISyncIcon,
label: 'OpenAPI',
rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>,
onClick: openOpenAPISyncTab
},
}] : []),
...(hasCopiedItems
? [
{

View File

@@ -2,7 +2,7 @@ import { useRef, useEffect, useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconCheck, IconX, IconSettings } from '@tabler/icons';
import get from 'lodash/get';
import path from 'path';
import path from 'utils/common/path';
import toast from 'react-hot-toast';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import path from 'path';
import path from 'utils/common/path';
import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Portal from 'components/Portal';

View File

@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import get from 'lodash/get';
import path from 'path';
import path from 'utils/common/path';
import { IconCaretDown } from '@tabler/icons';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { postmanToBruno } from 'utils/importers/postman-collection';
@@ -13,6 +13,7 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { processOpenCollection } from 'utils/importers/opencollection';
import { wsdlToBruno } from '@usebruno/converters';
import { toastError } from 'utils/common/error';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
import Modal from 'components/Modal';
import Help from 'components/Help';
import Dropdown from 'components/Dropdown';
@@ -101,13 +102,14 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour
const dispatch = useDispatch();
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(true);
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(isOpenAPISyncEnabled);
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';
const isZipImport = format === 'bruno-zip';
const isOpenApiFromUrl = isOpenApi && !!sourceUrl && !filePath;
const isOpenApiFromFile = isOpenApi && !!filePath && !sourceUrl;
const showCheckForSpecUpdatesOption = isOpenApiFromUrl || isOpenApiFromFile;
const showCheckForSpecUpdatesOption = isOpenAPISyncEnabled && (isOpenApiFromUrl || isOpenApiFromFile);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);

View File

@@ -104,7 +104,6 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
formik.setFieldValue('folderName', e.target.value);
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
}}
data-testid="new-folder-input"
value={formik.values.folderName || ''}
/>
{formik.touched.folderName && formik.errors.folderName ? (

View File

@@ -1,5 +1,4 @@
import { useState, useMemo, useEffect } from 'react';
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
import { useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
@@ -19,7 +18,7 @@ import {
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
@@ -59,22 +58,6 @@ const CollectionsSection = () => {
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
// Listen for sidebar-search-open hotkey event
useEffect(() => {
const handleSidebarSearch = () => {
setShowSearch(true);
// Focus the search input after it's rendered
setTimeout(() => {
const searchInput = document.querySelector('.collection-search-input');
if (searchInput) {
searchInput.focus();
}
}, 50);
};
window.addEventListener('sidebar-search-open', handleSidebarSearch);
return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch);
}, []);
// Default to true (don't show modal) so that:
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
// 2. The modal doesn't flash before preferences are loaded from the electron process

View File

@@ -7,7 +7,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
const CodeMirror = require('codemirror');
@@ -22,11 +21,8 @@ class SingleLineEditor extends Component {
this.variables = {};
this.readOnly = props.readOnly || false;
// Shortcuts cleanup function
this._shortcutsCleanup = null;
this.state = {
maskInput: props.isSecret || false
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
@@ -63,8 +59,8 @@ class SingleLineEditor extends Component {
readOnly: this.props.readOnly,
extraKeys: {
'Enter': runHandler,
// 'Ctrl-Enter': runHandler,
// 'Cmd-Enter': runHandler,
'Ctrl-Enter': runHandler,
'Cmd-Enter': runHandler,
'Alt-Enter': () => {
if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n');
@@ -73,7 +69,7 @@ class SingleLineEditor extends Component {
this.props.onRun();
}
},
// 'Shift-Enter': runHandler,
'Shift-Enter': runHandler,
'Cmd-S': saveHandler,
'Ctrl-S': saveHandler,
'Cmd-F': noopHandler,
@@ -112,9 +108,6 @@ class SingleLineEditor extends Component {
this._updateNewlineMarkers();
}
setupLinkAware(this.editor);
// Setup keyboard shortcuts using the dedicated utility
this._shortcutsCleanup = setupShortcuts(this.editor, this);
}
/** Enable or disable masking the rendered content of the editor */
@@ -186,6 +179,10 @@ class SingleLineEditor extends Component {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
// Update newline markers after value change
if (this.props.showNewlineArrow) {
@@ -209,12 +206,6 @@ class SingleLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();

View File

@@ -49,7 +49,10 @@ const StatusBar = () => {
};
const openGlobalSearch = () => {
window.dispatchEvent(new CustomEvent('global-search-open'));
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
bindings.forEach((binding) => {
Mousetrap.trigger(binding);
});
};
return (

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
padding: 9px 20px 8px 20px;
flex-shrink: 0;
.title {

View File

@@ -73,7 +73,7 @@ const StyledWrapper = styled.div`
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
border-radius: 6px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
@@ -111,7 +111,15 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
padding: 8px;
}
.section-header {
margin-inline: 4px;
padding-left: 6px;
border-radius: 6px;
padding-right: 3px;
padding-block: 4px;
}
.environments-list {
@@ -154,7 +162,7 @@ const StyledWrapper = styled.div`
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 5px;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {

View File

@@ -48,7 +48,6 @@ const EnvironmentList = ({
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
@@ -83,6 +82,8 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const globalEnvironmentDraftUid = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft?.environmentUid);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
@@ -90,10 +91,10 @@ const EnvironmentList = ({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
} else if (globalEnvironmentDraftUid?.startsWith('dotenv:')) {
dispatch(clearGlobalEnvironmentDraft());
}
}, [dispatch, selectedDotEnvFile]);
}, [dispatch, selectedDotEnvFile, globalEnvironmentDraftUid]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
@@ -552,51 +553,66 @@ const EnvironmentList = ({
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
className="btn-action"
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
if (!environmentsExpanded) {
setEnvironmentsExpanded(true);
}
handleCreateEnvClick();
}}
title="Search environments"
title="Create environment"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) {
setEnvironmentsExpanded(true);
}
handleImportClick();
}}
title="Import environment"
>
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) {
setEnvironmentsExpanded(true);
}
handleExportClick();
}}
title="Export environment"
>
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -6,12 +6,17 @@ import ExportEnvironmentModal from 'components/Environments/Common/ExportEnviron
const WorkspaceEnvironments = ({ workspace }) => {
const [isModified, setIsModified] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [showExportModal, setShowExportModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const [selectedEnvironment, setSelectedEnvironment] = useState(() => {
const environments = globalEnvironments || [];
if (!environments.length) return null;
return environments.find((env) => env.uid === activeGlobalEnvironmentUid) || environments[0];
});
return (
<StyledWrapper>
<EnvironmentList

View File

@@ -40,7 +40,7 @@ const CreateWorkspace = ({ onClose }) => {
if (!value) return true;
return !workspaces.some((w) =>
w.name.toLowerCase() === value.toLowerCase());
!w.isCreating && w.name && w.name.toLowerCase() === value.toLowerCase());
}),
workspaceFolderName: Yup.string()
.min(1, 'Must be at least 1 character')

View File

@@ -187,11 +187,10 @@ const GlobalStyle = createGlobalStyle`
// scrollbar styling
// the below media query target non-macos devices
// (macos scrollbar styling is the ideal style reference)
// the below media query targets non-touch devices
@media not all and (pointer: coarse) {
* {
scrollbar-color: ${(props) => props.theme.scrollbar.color};
scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent;
}
*::-webkit-scrollbar {

View File

@@ -0,0 +1,39 @@
import { useState, useEffect, useRef } from 'react';
/**
* A hook that defers showing loading state until a minimum delay has passed.
* This prevents flickering UI for fast operations.
*
* @param {boolean} isLoading - The actual loading state
* @param {number} delay - Minimum time (ms) before showing loading state (default: 200ms)
* @returns {boolean} - The deferred loading state
*/
function useDeferredLoading(isLoading, delay = 200) {
const [showLoading, setShowLoading] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
if (isLoading) {
timerRef.current = setTimeout(() => {
setShowLoading(true);
}, delay);
} else {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setShowLoading(false);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [isLoading, delay]);
return showLoading;
}
export default useDeferredLoading;

View File

@@ -0,0 +1,109 @@
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
import { renderHook, act } from '@testing-library/react';
import useDeferredLoading from './index';
describe('useDeferredLoading', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should return false initially when isLoading is false', () => {
const { result } = renderHook(() => useDeferredLoading(false));
expect(result.current).toBe(false);
});
it('should not show loading immediately when isLoading becomes true', () => {
const { result } = renderHook(() => useDeferredLoading(true, 200));
expect(result.current).toBe(false);
});
it('should show loading after the delay has passed', () => {
const { result } = renderHook(() => useDeferredLoading(true, 200));
expect(result.current).toBe(false);
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current).toBe(true);
});
it('should not show loading if isLoading becomes false before delay', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useDeferredLoading(isLoading, 200),
{ initialProps: { isLoading: true } }
);
expect(result.current).toBe(false);
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toBe(false);
rerender({ isLoading: false });
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current).toBe(false);
});
it('should reset to false immediately when isLoading becomes false', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useDeferredLoading(isLoading, 200),
{ initialProps: { isLoading: true } }
);
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current).toBe(true);
rerender({ isLoading: false });
expect(result.current).toBe(false);
});
it('should use default delay of 200ms', () => {
const { result } = renderHook(() => useDeferredLoading(true));
expect(result.current).toBe(false);
act(() => {
jest.advanceTimersByTime(199);
});
expect(result.current).toBe(false);
act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe(true);
});
it('should respect custom delay values', () => {
const { result } = renderHook(() => useDeferredLoading(true, 500));
act(() => {
jest.advanceTimersByTime(400);
});
expect(result.current).toBe(false);
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toBe(true);
});
});

View File

@@ -2,14 +2,16 @@ import { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { checkActiveWorkspaceCollectionsForUpdates } from 'providers/ReduxStore/slices/openapi-sync';
import { normalizePath } from 'utils/common/path';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
const useOpenAPISyncPolling = () => {
const dispatch = useDispatch();
// Global toggle for pausing all OpenAPI sync polling (defaults to true, not yet wired to any UI)
const pollingEnabled = useSelector((state) => state.openapiSync?.pollingEnabled ?? true);
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
// Global toggle for pausing all OpenAPI sync polling
const pollingEnabled = useSelector((state) => state.openapiSync?.pollingEnabled ?? true) && isOpenAPISyncEnabled;
const collections = useSelector((state) => state.collections?.collections || []);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);

View File

@@ -1,366 +1,290 @@
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
import React, { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import toast from 'react-hot-toast';
import { useSelector } from 'react-redux';
import NewRequest from 'components/Sidebar/NewRequest';
import { useSelector, useDispatch } from 'react-redux';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import GlobalSearchModal from 'components/GlobalSearchModal';
import ImportCollection from 'components/Sidebar/ImportCollection';
import store from 'providers/ReduxStore/index';
import {
sendRequest,
saveRequest,
saveCollectionRoot,
saveFolderRoot,
saveCollectionSettings,
closeTabs,
cloneItem,
pasteItem
closeTabs
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
export const HotkeysContext = createContext(null);
export const HotkeysContext = React.createContext();
// List of all actions that are bound in this provider
const BOUND_ACTIONS = [
'save',
'sendRequest',
'editEnvironment',
'newRequest',
'globalSearch',
'closeTab',
'switchToPreviousTab',
'switchToNextTab',
'closeAllTabs',
'collapseSidebar',
'moveTabLeft',
'moveTabRight',
'changeLayout',
'closeBruno',
'openPreferences',
'importCollection',
'sidebarSearch',
'zoomIn',
'zoomOut',
'resetZoom',
'cloneItem',
'copyItem',
'pasteItem',
'renameItem'
];
/**
* Bind a single hotkey action using Mousetrap.
* Reads from merged defaults + user preferences via getKeyBindingsForActionAllOS.
*/
function bindHotkey(action, handler, userKeyBindings) {
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
if (!combos?.length) return;
Mousetrap.bind([...combos], (e) => {
e?.preventDefault?.();
handler(e);
return false;
});
}
/**
* Unbind a single hotkey action.
*/
function unbindHotkey(action, userKeyBindings) {
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
if (!combos?.length) return;
Mousetrap.unbind([...combos]);
}
/**
* Unbind all known actions for the given user key bindings.
*/
function unbindAllHotkeys(userKeyBindings) {
BOUND_ACTIONS.forEach((action) => unbindHotkey(action, userKeyBindings));
}
/**
* Bind all hotkey actions.
*/
function bindAllHotkeys(userKeyBindings) {
const { dispatch, getState } = store;
// SAVE
bindHotkey('save', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
window.dispatchEvent(new CustomEvent('environment-save'));
return;
}
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, activeTab.uid);
if (item?.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
}
}, userKeyBindings);
// SEND REQUEST
bindHotkey('sendRequest', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, activeTab.uid);
if (!item) return;
if (item.type === 'grpc-request') {
const request = item.draft ? item.draft.request : item.request;
if (!request.url) return toast.error('Please enter a valid gRPC server URL');
if (!request.method) return toast.error('Please select a gRPC method');
}
dispatch(sendRequest(item, collection.uid)).catch(() =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { duration: 5000 })
);
}, userKeyBindings);
// EDIT ENV
bindHotkey('editEnvironment', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}, userKeyBindings);
// NEW REQUEST -> trigger via event so the provider can open the modal
bindHotkey('newRequest', () => {
window.dispatchEvent(new CustomEvent('new-request-open'));
}, userKeyBindings);
// GLOBAL SEARCH -> trigger via event so the provider can open the modal
bindHotkey('globalSearch', () => {
window.dispatchEvent(new CustomEvent('global-search-open'));
}, userKeyBindings);
// CLOSE TAB
bindHotkey('closeTab', () => {
window.dispatchEvent(new CustomEvent('close-active-tab'));
}, userKeyBindings);
// SWITCH PREV TAB
bindHotkey('switchToPreviousTab', () => {
dispatch(switchTab({ direction: 'pageup' }));
}, userKeyBindings);
// SWITCH NEXT TAB
bindHotkey('switchToNextTab', () => {
dispatch(switchTab({ direction: 'pagedown' }));
}, userKeyBindings);
// CLOSE ALL TABS
bindHotkey('closeAllTabs', () => {
window.dispatchEvent(new CustomEvent('close-active-tab'));
}, userKeyBindings);
// COLLAPSE SIDEBAR
bindHotkey('collapseSidebar', () => {
dispatch(toggleSidebarCollapse());
}, userKeyBindings);
// MOVE TAB LEFT
bindHotkey('moveTabLeft', () => {
dispatch(reorderTabs({ direction: -1 }));
}, userKeyBindings);
// MOVE TAB RIGHT
bindHotkey('moveTabRight', () => {
dispatch(reorderTabs({ direction: 1 }));
}, userKeyBindings);
// CHANGE LAYOUT -> toggle response pane orientation
bindHotkey('changeLayout', () => {
const state = getState();
const preferences = state.app.preferences;
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences.layout,
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
}, userKeyBindings);
// CLOSE BRUNO -> send IPC to close the window
bindHotkey('closeBruno', () => {
window.ipcRenderer?.send('renderer:window-close');
}, userKeyBindings);
// OPEN PREFERENCES -> open preferences tab
bindHotkey('openPreferences', () => {
const state = getState();
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = tabs.find((t) => t.uid === activeTabUid);
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}, userKeyBindings);
// IMPORT COLLECTION -> trigger event to open import modal
bindHotkey('importCollection', () => {
window.dispatchEvent(new CustomEvent('import-collection-open'));
}, userKeyBindings);
// SIDEBAR SEARCH -> trigger event to focus sidebar search
bindHotkey('sidebarSearch', () => {
window.dispatchEvent(new CustomEvent('sidebar-search-open'));
}, userKeyBindings);
// ZOOM IN
bindHotkey('zoomIn', () => {
window.ipcRenderer?.invoke('renderer:zoom-in');
}, userKeyBindings);
// ZOOM OUT
bindHotkey('zoomOut', () => {
window.ipcRenderer?.invoke('renderer:zoom-out');
}, userKeyBindings);
// RESET ZOOM
bindHotkey('resetZoom', () => {
window.ipcRenderer?.invoke('renderer:reset-zoom');
}, userKeyBindings);
// CLONE ITEM -> trigger event so the sidebar can handle opening the clone modal
bindHotkey('cloneItem', () => {
window.dispatchEvent(new CustomEvent('clone-item-open'));
}, userKeyBindings);
// COPY ITEM -> copy currently selected item to clipboard
bindHotkey('copyItem', () => {
window.dispatchEvent(new CustomEvent('copy-item-open'));
}, userKeyBindings);
// PASTE ITEM -> paste from clipboard to current location
bindHotkey('pasteItem', () => {
window.dispatchEvent(new CustomEvent('paste-item-open'));
}, userKeyBindings);
// RENAME ITEM -> trigger event so the sidebar can handle opening the rename modal
bindHotkey('renameItem', () => {
window.dispatchEvent(new CustomEvent('rename-item-open'));
}, userKeyBindings);
}
// -----------------------
// Provider (manages hotkey lifecycle + modal state)
// -----------------------
export const HotkeysProvider = (props) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
// Keep a ref to the previous userKeyBindings so we can unbind old combos
const prevKeyBindingsRef = useRef(undefined);
const getCurrentCollection = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return undefined;
return findCollectionByUid(collections, activeTab.collectionUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
return collection;
}
};
const currentCollection = getCurrentCollection();
// Bind/rebind hotkeys whenever user preferences change
// save hotkey
useEffect(() => {
// Store previous bindings before updating
const prevBindings = prevKeyBindingsRef.current;
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
window.dispatchEvent(new CustomEvent('environment-save'));
return false;
}
// Unbind previous bindings (if any)
if (prevBindings !== undefined) {
unbindAllHotkeys(prevBindings);
}
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
}
}
}
// Bind with current preferences
bindAllHotkeys(userKeyBindings);
prevKeyBindingsRef.current = userKeyBindings;
return false; // this stops the event bubbling
});
return () => {
// Cleanup on unmount
unbindAllHotkeys(userKeyBindings);
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
};
}, [userKeyBindings]);
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
// Listen for hotkey-triggered events for modals
// send request (ctrl/cmd + enter)
useEffect(() => {
const openNewRequest = () => setShowNewRequestModal(true);
const openGlobalSearch = () => setShowGlobalSearchModal(true);
const openImportCollection = () => setShowImportCollectionModal(true);
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
window.addEventListener('new-request-open', openNewRequest);
window.addEventListener('global-search-open', openGlobalSearch);
window.addEventListener('import-collection-open', openImportCollection);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item) {
if (item.type === 'grpc-request') {
const request = item.draft ? item.draft.request : item.request;
if (!request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if (!request.method) {
toast.error('Please select a gRPC method');
return;
}
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
);
}
}
}
return false; // this stops the event bubbling
});
return () => {
window.removeEventListener('new-request-open', openNewRequest);
window.removeEventListener('global-search-open', openGlobalSearch);
window.removeEventListener('import-collection-open', openImportCollection);
Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
};
}, [activeTabUid, tabs, saveRequest, collections]);
// edit environments (ctrl/cmd + e)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
};
}, [activeTabUid, tabs, collections, dispatch]);
// new request (ctrl/cmd + b)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
setShowNewRequestModal(true);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// global search (ctrl/cmd + k)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
setShowGlobalSearchModal(true);
return false; // stop bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
};
}, []);
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
if (activeTabUid) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
}, [activeTabUid]);
// Switch to the previous tab
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
dispatch(
switchTab({
direction: 'pageup'
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
};
}, [dispatch]);
// Switch to the next tab
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
dispatch(
switchTab({
direction: 'pagedown'
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
};
}, [dispatch]);
// Close all tabs
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
dispatch(
closeTabs({
tabUids: tabUids
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
};
}, [activeTabUid, tabs, collections, dispatch]);
// Collapse sidebar (ctrl/cmd + \)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
dispatch(toggleSidebarCollapse());
return false;
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
};
}, [dispatch]);
// Move tab left
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
dispatch(reorderTabs({ direction: -1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
};
}, [dispatch]);
// Move tab right
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
dispatch(reorderTabs({ direction: 1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
};
}, [dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showNewRequestModal && (
@@ -369,16 +293,13 @@ export const HotkeysProvider = (props) => {
{showGlobalSearchModal && (
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
)}
{showImportCollectionModal && (
<ImportCollection onClose={() => setShowImportCollectionModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>
);
};
export const useHotkeys = () => {
const context = useContext(HotkeysContext);
const context = React.useContext(HotkeysContext);
if (!context) {
throw new Error(`useHotkeys must be used within a HotkeysProvider`);

View File

@@ -1,76 +1,42 @@
export const DEFAULT_KEY_BINDINGS = {
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
const KeyMapping = {
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
closeBruno: {
mac: 'command+bind+q',
windows: 'ctrl+bind+shift+bind+q',
mac: 'command+Q',
windows: 'ctrl+shift+q',
name: 'Close Bruno'
},
switchToPreviousTab: {
mac: 'command+bind+2',
windows: 'ctrl+bind+2',
mac: 'command+pageup',
windows: 'ctrl+pageup',
name: 'Switch to Previous Tab'
},
switchToNextTab: {
mac: 'command+bind+1',
windows: 'ctrl+bind+1',
mac: 'command+pagedown',
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
moveTabLeft: {
mac: 'command+bind+[',
windows: 'ctrl+bind+[',
mac: 'command+shift+pageup',
windows: 'ctrl+shift+pageup',
name: 'Move Tab Left'
},
moveTabRight: {
mac: 'command+bind+]',
windows: 'ctrl+bind+]',
mac: 'command+shift+pagedown',
windows: 'ctrl+shift+pagedown',
name: 'Move Tab Right'
},
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
};
/**
* Converts keybindings from storage format (+bind+) to Mousetrap format (+)
* Storage format uses +bind+ as separator to avoid conflicts with the actual + key
* Mousetrap uses + as the separator
* Also converts arrow key names to Mousetrap format
*
* @param {string} keysStr - Keybinding string in storage format
* @returns {string|null} Keybinding string in Mousetrap format, or null if empty
*/
export const toMousetrapCombo = (keysStr) => {
if (!keysStr) return null;
// Split by +bind+ separator
const parts = keysStr.split('+bind+').filter(Boolean);
// Convert arrow key names from browser format to Mousetrap format
const converted = parts.map((part) => {
const lower = part.toLowerCase();
if (lower === 'arrowup') return 'up';
if (lower === 'arrowdown') return 'down';
if (lower === 'arrowleft') return 'left';
if (lower === 'arrowright') return 'right';
return lower;
});
return converted.join('+');
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' },
renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' }
};
/**
@@ -81,7 +47,7 @@ export const toMousetrapCombo = (keysStr) => {
*/
export const getKeyBindingsForOS = (os) => {
const keyBindings = {};
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
if (keys[os]) {
keyBindings[action] = {
keys: keys[os],
@@ -93,57 +59,18 @@ export const getKeyBindingsForOS = (os) => {
};
/**
* Merges default key bindings with user preferences.
*
* @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings)
* @returns {Object} Merged key bindings object
*/
export const getMergedKeyBindings = (userKeyBindings) => {
const merged = {};
// Start with defaults
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
merged[action] = { ...binding };
}
// Override with user preferences
if (userKeyBindings && typeof userKeyBindings === 'object') {
for (const [action, binding] of Object.entries(userKeyBindings)) {
if (merged[action]) {
merged[action] = { ...merged[action], ...binding };
}
}
}
return merged;
};
/**
* Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems.
* Reads from merged defaults + user preferences.
* Retrieves the key bindings for a specific action across all operating systems.
*
* @param {string} action - The action for which to retrieve key bindings.
* @param {Object} [userKeyBindings] - User's custom key bindings from preferences
* @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found.
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
*/
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
const merged = getMergedKeyBindings(userKeyBindings);
const actionBindings = merged[action];
export const getKeyBindingsForActionAllOS = (action) => {
const actionBindings = KeyMapping[action];
if (!actionBindings) {
console.warn(`Action "${action}" not found in KeyMapping.`);
return null;
}
const combos = [];
if (actionBindings.mac) {
const combo = toMousetrapCombo(actionBindings.mac);
if (combo) combos.push(combo);
}
if (actionBindings.windows) {
const combo = toMousetrapCombo(actionBindings.windows);
if (combo) combos.push(combo);
}
return combos.length > 0 ? combos : null;
return [actionBindings.mac, actionBindings.windows];
};

View File

@@ -9,7 +9,9 @@ const initialState = {
// Last poll timestamp
lastPollTime: null,
// Map of collectionUid -> { activeTab, expandedSections, expandedRows }
tabUiState: {}
tabUiState: {},
// Map of collectionUid -> { title, version, endpointCount } (persists across tab navigations)
storedSpecMeta: {}
};
export const openapiSyncSlice = createSlice({
@@ -33,6 +35,11 @@ export const openapiSyncSlice = createSlice({
const { collectionUid } = action.payload;
delete state.collectionUpdates[collectionUid];
delete state.tabUiState[collectionUid];
delete state.storedSpecMeta[collectionUid];
},
setStoredSpecMeta: (state, action) => {
const { collectionUid, title, version, endpointCount } = action.payload;
state.storedSpecMeta[collectionUid] = { title, version, endpointCount };
},
setPollingEnabled: (state, action) => {
state.pollingEnabled = action.payload;
@@ -116,7 +123,8 @@ export const {
toggleRowExpanded,
setLastPollTime,
setReviewDecision,
setReviewDecisions
setReviewDecisions,
setStoredSpecMeta
} = openapiSyncSlice.actions;
// Lightweight thunk for polling — only checks hash, no deep comparison
@@ -199,4 +207,9 @@ export const selectTabUiState = (collectionUid) => (state) => {
return state.openapiSync?.tabUiState?.[collectionUid] || {};
};
// Selector for stored spec metadata (title, version, endpointCount)
export const selectStoredSpecMeta = (collectionUid) => (state) => {
return state.openapiSync?.storedSpecMeta?.[collectionUid] || null;
};
export default openapiSyncSlice.reducer;

View File

@@ -1,22 +1,20 @@
import path from 'path';
import path from 'utils/common/path';
import {
createWorkspace,
removeWorkspace,
setActiveWorkspace,
updateWorkspace,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
updateWorkspaceLoadingState,
setWorkspaceScratchCollection
} from '../workspaces';
import { showHomePage } from '../app';
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
import { sanitizeName } from 'utils/common/regex';
import { clearCollectionState } from '../openapi-sync';
import { updateGlobalEnvironments } from '../global-environments';
import { addTab, focusTab } from '../tabs';
import { normalizePath } from 'utils/common/path';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
const { ipcRenderer } = window;
@@ -53,20 +51,112 @@ const transformCollection = async (collection, type) => {
};
/**
* Creates a workspace with a unique name under the given location
* Creates a temporary workspace in Redux without touching the filesystem.
* The workspace is only persisted to disk when the user confirms the name.
*/
export const createWorkspaceWithUniqueName = (location) => {
return async (dispatch) => {
const { uuid: generateUuid } = await import('utils/common');
const tempUid = generateUuid();
const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Workspace', location) || 'Untitled Workspace';
const folderName = sanitizeName(name);
const result = await dispatch(createWorkspaceAction(name, folderName, location));
if (result?.workspaceUid) {
dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true }));
dispatch(createWorkspace({
uid: tempUid,
name,
pathname: null,
collections: [],
isCreating: true,
creationLocation: location
}));
dispatch(updateWorkspace({ uid: tempUid, isNewlyCreated: true }));
await dispatch(switchWorkspace(tempUid));
return { workspaceUid: tempUid };
};
};
/**
* Confirms creation of a temporary workspace by persisting it to the filesystem.
*/
export const confirmWorkspaceCreation = (tempWorkspaceUid, workspaceName) => {
return async (dispatch, getState) => {
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
if (!tempWorkspace) {
throw new Error('Temporary workspace not found');
}
const location = tempWorkspace.creationLocation;
if (!location) {
throw new Error('Workspace creation location not found');
}
const baseFolderName = sanitizeName(workspaceName);
const folderName = await ipcRenderer?.invoke('renderer:find-unique-folder-name', baseFolderName, location) || baseFolderName;
const result = await ipcRenderer.invoke(
'renderer:create-workspace',
workspaceName,
folderName,
location
);
const { workspaceUid: realUid, workspacePath, workspaceConfig } = result;
// Clean up the temp workspace's scratch collection after IPC succeeds
// (doing it before would leave a broken state if the IPC call fails)
if (tempWorkspace.scratchCollectionUid) {
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
}
// Remove the temporary workspace
dispatch(removeWorkspace(tempWorkspaceUid));
// Ensure the real workspace exists in Redux (the workspace-opened event may or may not have fired yet)
const existing = getState().workspaces.workspaces.find((w) => w.uid === realUid);
if (!existing) {
dispatch(createWorkspace({
uid: realUid,
pathname: workspacePath,
...workspaceConfig
}));
}
dispatch(updateWorkspace({ uid: realUid, name: workspaceName }));
await dispatch(switchWorkspace(realUid));
return result;
};
};
/**
* Cancels creation of a temporary workspace, removing it from Redux.
* Only switches to default workspace if the temp workspace was the active one.
*/
export const cancelWorkspaceCreation = (tempWorkspaceUid) => {
return async (dispatch, getState) => {
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
if (!tempWorkspace) return;
// Clean up the scratch collection if one was mounted
if (tempWorkspace.scratchCollectionUid) {
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
}
const wasActive = getState().workspaces.activeWorkspaceUid === tempWorkspaceUid;
dispatch(removeWorkspace(tempWorkspaceUid));
// Only switch to default if the cancelled workspace was the active one
if (wasActive) {
const defaultWorkspace = getState().workspaces.workspaces.find((w) => w.type === 'default');
if (defaultWorkspace) {
await dispatch(switchWorkspace(defaultWorkspace.uid));
}
}
};
};
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
return async (dispatch) => {
try {

View File

@@ -5,7 +5,8 @@ import { useSelector } from 'react-redux';
* Contains all available beta feature keys
*/
export const BETA_FEATURES = Object.freeze({
NODE_VM: 'nodevm'
NODE_VM: 'nodevm',
OPENAPI_SYNC: 'openapi-sync'
});
/**

View File

@@ -1,233 +0,0 @@
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import store from 'providers/ReduxStore/index';
import { reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { savePreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
const CodeMirror = require('codemirror');
const KEYBINDING_ACTIONS = [
{
actionName: 'closeTab',
handler: () => {
window.dispatchEvent(new CustomEvent('close-active-tab'));
return true;
}
},
{
actionName: 'sendRequest',
handler: (context) => {
if (context?.props?.onRun) context.props.onRun();
return true;
}
},
{
actionName: 'switchToPreviousTab',
handler: () => {
store.dispatch(switchTab({ direction: 'pageup' }));
return true;
}
},
{
actionName: 'switchToNextTab',
handler: () => {
store.dispatch(switchTab({ direction: 'pagedown' }));
return true;
}
},
{
actionName: 'moveTabLeft',
handler: () => {
store.dispatch(reorderTabs({ direction: -1 }));
return true;
}
},
{
actionName: 'moveTabRight',
handler: () => {
store.dispatch(reorderTabs({ direction: 1 }));
return true;
}
},
{
actionName: 'changeLayout',
handler: () => {
const state = store.getState();
const preferences = state.app.preferences;
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences.layout,
responsePaneOrientation: newOrientation
}
};
store.dispatch(savePreferences(updatedPreferences));
return true;
}
},
{
actionName: 'collapseSidebar',
handler: () => {
store.dispatch(toggleSidebarCollapse());
return true;
}
}
];
/**
* Converts user keybinding format to CodeMirror format
* e.g., "command+bind+enter" -> "Cmd-Enter"
* @param {string} combo - The keybinding combo string
* @returns {string|null} CodeMirror formatted combo or null
*/
function convertToCodeMirrorFormat(combo) {
if (!combo || typeof combo !== 'string') return null;
const normalized = combo
.replace(/-/g, '+')
.split('+')
.map((p) => p.trim())
.filter(Boolean)
.filter((p) => p.toLowerCase() !== 'bind')
.join('+');
const parts = normalized.split('+').map((p) => p.trim()).filter(Boolean);
const out = parts.map((key) => {
const lower = key.toLowerCase();
if (lower === 'command' || lower === 'cmd') return 'Cmd';
if (lower === 'control' || lower === 'ctrl') return 'Ctrl';
if (lower === 'option' || lower === 'alt') return 'Alt';
if (lower === 'shift') return 'Shift';
if (lower === 'mod') return 'Mod';
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'esc' || lower === 'escape') return 'Esc';
if (lower === 'space') return 'Space';
if (lower === 'tab') return 'Tab';
if (lower === 'backspace') return 'Backspace';
if (lower === 'delete' || lower === 'del') return 'Delete';
if (lower === 'up') return 'Up';
if (lower === 'down') return 'Down';
if (lower === 'left') return 'Left';
if (lower === 'right') return 'Right';
if (key.length === 1) return key.toUpperCase();
return key.charAt(0).toUpperCase() + key.slice(1);
});
return out.join('-');
}
/**
* Builds a consolidated CodeMirror keymap from all configured keybinding actions.
* Uses CodeMirror.Pass for non-matching keys to allow default behavior.
* @param {Object} context - Context object containing props and other editor context
* @returns {Object} CodeMirror keymap object
*/
function buildKeymap(context) {
let state;
try {
const reduxState = store.getState();
state = reduxState;
} catch (e) {
state = { app: { preferences: {} } };
}
const userKeyBindings = state.app.preferences?.keyBindings || {};
// Create a comprehensive keymap with CodeMirror.Pass as fallthrough
// This allows non-matching keys to pass through to default CodeMirror behavior
const keyMap = {
name: 'singleLineEditor.custom',
// CodeMirror.Pass tells CodeMirror to pass this key event to the next keymap
// This is the key to making non-configured keys work normally
fallthrough: CodeMirror.Pass
};
// Build keymap entries for each configured action
KEYBINDING_ACTIONS.forEach(({ actionName, handler }) => {
const combos = getKeyBindingsForActionAllOS(actionName, userKeyBindings) || [];
const cmCombos = combos
.map((k) => convertToCodeMirrorFormat(k))
.filter(Boolean);
if (cmCombos.length > 0) {
cmCombos.forEach((cmKey) => {
// Create handler that passes context as argument
keyMap[cmKey] = () => handler(context);
});
}
});
return keyMap;
}
/**
* Sets up keyboard shortcuts for a CodeMirror editor instance.
* This enables custom keybindings with CodeMirror.Pass fallthrough support.
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} context - Context object containing props and other editor context
* @returns {Object} Cleanup function to remove the keymap
*/
function setupShortcuts(editor, context = {}) {
if (!editor) {
return () => { };
}
let currentKeyMap = null;
let unsubscribeStore = null;
/**
* Apply the consolidated custom keymap to the CodeMirror editor
*/
const applyKeyMap = () => {
if (!editor) return;
// Remove existing custom keymap if any
if (currentKeyMap) {
try {
editor.removeKeyMap(currentKeyMap);
} catch (e) {
console.warn('[SingleLineEditor] Error removing keymap:', e);
}
}
// Build and apply new consolidated keymap
currentKeyMap = buildKeymap(context);
editor.addKeyMap(currentKeyMap);
};
// Apply keymap on setup
applyKeyMap();
// Subscribe to store changes to rebuild keymap when preferences change
unsubscribeStore = store.subscribe(() => {
applyKeyMap();
});
/**
* Cleanup function to remove the keymap and unsubscribe from store
*/
const cleanup = () => {
if (unsubscribeStore) {
unsubscribeStore();
unsubscribeStore = null;
}
if (editor && currentKeyMap) {
try {
editor.removeKeyMap(currentKeyMap);
} catch (e) {
console.warn('[SingleLineEditor] Error removing keymap on cleanup:', e);
}
currentKeyMap = null;
}
};
return cleanup;
}
export { setupShortcuts, buildKeymap, convertToCodeMirrorFormat, KEYBINDING_ACTIONS };

View File

@@ -902,7 +902,7 @@ describe('parseCurlCommand', () => {
{ name: 'test', value: 'urlquery' },
{ name: 'name', value: 'John%20Doe' },
{ name: 'email', value: 'john@example.com' },
{ name: 'hello', value: '' }
{ name: 'hello', value: undefined }
]
});
});

View File

@@ -53,13 +53,13 @@
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/requests": "^0.1.0",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"decomment": "^0.9.5",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",

View File

@@ -2,20 +2,16 @@ const qs = require('qs');
const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const { forOwn, each, extend, get, compact } = require('lodash');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString, interpolateObject } = require('./interpolate-string');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const https = require('node:https');
const http = require('node:http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const { setupProxyAgents } = require('../utils/proxy-util');
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
@@ -23,10 +19,10 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -429,90 +425,15 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
// Prepare TLS options for agent caching
const tlsOptions = {
...httpsAgentRequestFields
};
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(request.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
// Only set the agent needed for the request protocol
if (socksEnabled) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
}
if (!request.httpAgent && !request.httpsAgent) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
}
setupProxyAgents({
requestConfig: request,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
// set cookies if enabled
if (!options.disableCookies) {
@@ -559,18 +480,23 @@ const runSingleRequest = async function (
// if `data` is of string type - return as-is (assumes already encoded)
}
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
if (!isFormData(request?.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);
request.data = form;
if (request?.headers?.['content-type'] !== 'multipart/form-data') {
if (contentType !== 'multipart/form-data') {
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
const formHeaders = form.getHeaders();
const ct = request.headers['content-type'];
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
const existingBoundary = extractBoundaryFromContentType(contentType);
if (existingBoundary) {
formHeaders['content-type'] = contentType;
} else {
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
}
form.getHeaders = function () {
return formHeaders;
};
@@ -688,7 +614,13 @@ const runSingleRequest = async function (
let axiosInstance = makeAxiosInstance({
requestMaxRedirects: requestMaxRedirects,
disableCookies: options.disableCookies,
followRedirects: followRedirects
followRedirects: followRedirects,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (request.ntlmConfig) {

View File

@@ -2,6 +2,7 @@ const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const { createFormData } = require('./form-data');
const { setupProxyAgents } = require('./proxy-util');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
@@ -71,7 +72,17 @@ const createRedirectConfig = (error, redirectUrl) => {
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedirects = true } = {}) {
function makeAxiosInstance({
requestMaxRedirects = 5,
disableCookies,
followRedirects = true,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
} = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
@@ -167,6 +178,16 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi
const requestConfig = createRedirectConfig(error, redirectUrl);
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {

View File

@@ -1,6 +1,12 @@
const parseUrl = require('url').parse;
const { isEmpty } = require('lodash');
const http = require('node:http');
const https = require('node:https');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { interpolateString } = require('../runner/interpolate-string');
const DEFAULT_PORTS = {
ftp: 21,
@@ -96,7 +102,103 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache = true
}) {
// Clear stale agents so we always recreate them for the current URL
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
delete requestConfig.httpAgent;
delete requestConfig.httpsAgent;
const tlsOptions = { ...httpsAgentRequestFields };
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(requestConfig.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol?.includes('socks') ?? false;
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
// Only set the agent needed for the request protocol
if (socksEnabled) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
}
}
module.exports = {
shouldUseProxy,
PatchedHttpsProxyAgent
PatchedHttpsProxyAgent,
setupProxyAgents
};

View File

@@ -46,7 +46,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
import { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } from './form-data';
import FormData from 'form-data';
describe('buildFormUrlEncodedPayload', () => {
@@ -161,3 +161,51 @@ describe('isFormData', () => {
expect(isFormData(formData)).toBe(true);
});
});
describe('extractBoundaryFromContentType', () => {
it('should extract boundary from Content-Type header', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary')).toBe('my-boundary');
});
it('should extract boundary with dashes', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW')).toBe('----WebKitFormBoundary7MA4YWxkTrZu0gW');
});
it('should extract boundary case-insensitively', () => {
expect(extractBoundaryFromContentType('multipart/mixed; BOUNDARY=my-boundary')).toBe('my-boundary');
expect(extractBoundaryFromContentType('multipart/mixed; Boundary=my-boundary')).toBe('my-boundary');
});
it('should extract boundary when other params exist', () => {
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary=my-boundary')).toBe('my-boundary');
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary; charset=utf-8')).toBe('my-boundary');
});
it('should return null when no boundary exists', () => {
expect(extractBoundaryFromContentType('multipart/mixed')).toBeNull();
expect(extractBoundaryFromContentType('application/json')).toBeNull();
});
it('should return null for non-string input', () => {
expect(extractBoundaryFromContentType(null)).toBeNull();
expect(extractBoundaryFromContentType(undefined)).toBeNull();
expect(extractBoundaryFromContentType(123)).toBeNull();
expect(extractBoundaryFromContentType({})).toBeNull();
});
it('should handle empty string', () => {
expect(extractBoundaryFromContentType('')).toBeNull();
});
it('should extract boundary from quoted value', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my-boundary"')).toBe('my-boundary');
});
it('should extract quoted boundary with spaces', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my boundary value"')).toBe('my boundary value');
});
it('should extract quoted boundary when other params exist', () => {
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary="my-boundary"')).toBe('my-boundary');
});
});

View File

@@ -43,3 +43,16 @@ export const isFormData = (obj: unknown): boolean => {
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
return obj?.constructor?.name === 'FormData';
};
/**
* Extracts boundary parameter from a Content-Type header value.
* @param contentType - The Content-Type header value (e.g., "multipart/mixed; boundary=my-boundary")
* @returns The boundary value if found, or null if not present
*/
export const extractBoundaryFromContentType = (contentType: unknown): string | null => {
if (typeof contentType !== 'string') {
return null;
}
const match = contentType.match(/boundary="([^"]+)"|boundary=([^;\s]+)/i);
return match ? (match[1] || match[2]) : null;
};

View File

@@ -7,7 +7,8 @@ export {
export {
buildFormUrlEncodedPayload,
isFormData
isFormData,
extractBoundaryFromContentType
} from './form-data';
export {

View File

@@ -50,6 +50,18 @@ describe('encodeUrl', () => {
expect(encodeUrl(url)).toBe(expected);
});
it('should handle query parameters without values (no = sign)', () => {
const url = 'https://example.com/api?flag&age=25&verbose';
const expected = 'https://example.com/api?flag&age=25&verbose';
expect(encodeUrl(url)).toBe(expected);
});
it('should handle mixed empty-value and no-value parameters', () => {
const url = 'https://example.com/api?seat=&table=2&flag';
const expected = 'https://example.com/api?seat=&table=2&flag';
expect(encodeUrl(url)).toBe(expected);
});
it('should encode query parameters with pipe operator', () => {
const url = 'https://example.com/api?filter=status|active&sort=name|asc&tags=frontend|backend|api';
const expected = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi';
@@ -159,7 +171,7 @@ describe('parseQueryParams', () => {
expect(result).toEqual([]);
});
it('should handle query parameters with empty values', () => {
it('should handle query parameters with empty values (has = sign)', () => {
const queryString = 'name=&age=25&active=';
const result = parseQueryParams(queryString);
expect(result).toEqual([
@@ -169,6 +181,16 @@ describe('parseQueryParams', () => {
]);
});
it('should handle query parameters without values (no = sign)', () => {
const queryString = 'flag&age=25&verbose';
const result = parseQueryParams(queryString);
expect(result).toEqual([
{ name: 'flag', value: undefined },
{ name: 'age', value: '25' },
{ name: 'verbose', value: undefined }
]);
});
it('should extract query parameters with pipe operator', () => {
const queryString = 'filter=status|active&sort=name|asc&tags=frontend|backend';
const result = parseQueryParams(queryString);
@@ -218,4 +240,23 @@ describe('buildQueryString', () => {
const result = buildQueryString(params, { encode: false });
expect(result).toBe('filter=status|active&sort=name|asc');
});
it('should omit = for params with undefined value', () => {
const params = [
{ name: 'flag', value: undefined },
{ name: 'age', value: '25' },
{ name: 'verbose' }
];
const result = buildQueryString(params);
expect(result).toBe('flag&age=25&verbose');
});
it('should include = for params with empty string value', () => {
const params = [
{ name: 'seat', value: '' },
{ name: 'table', value: '2' }
];
const result = buildQueryString(params);
expect(result).toBe('seat=&table=2');
});
});

View File

@@ -16,8 +16,12 @@ function buildQueryString(paramsArray: QueryParam[], { encode = false }: BuildQu
.filter(({ name }) => typeof name === 'string' && name.trim().length > 0)
.map(({ name, value }) => {
const finalName = encode ? encodeURIComponent(name) : name;
const finalValue = encode ? encodeURIComponent(value ?? '') : (value ?? '');
if (value === undefined) {
return finalName;
}
const finalValue = encode ? encodeURIComponent(value) : value;
return `${finalName}=${finalValue}`;
})
.join('&');
@@ -39,9 +43,13 @@ function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsO
return null;
}
// Distinguish between ?param (no '=' at all) and ?param= (has '=' with empty value)
const hasEqualsSign = pair.includes('=');
const value = hasEqualsSign ? (decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')) : undefined;
return {
name: decode ? decodeURIComponent(name) : name,
value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
value
};
}).filter((param): param is NonNullable<typeof param> => param !== null);

View File

@@ -110,7 +110,7 @@ export const validateSchema = (collection = {}) => {
return collection;
} catch (err) {
console.log('Error validating schema', err);
throw new Error('The Collection has an invalid schema');
throw new Error(`The Collection has an invalid schema: ${err.message}`);
}
};

View File

@@ -73,6 +73,55 @@ const isItemAFolder = (item) => {
return !item.request;
};
/**
* Postman allows non-string values (e.g. numbers) in fields like header values,
* query param values, etc. Bruno expects these to be strings.
* Converts non-null/non-empty values to strings, returns fallback for null/undefined/empty.
*/
const ensureString = (value, fallback = '') => {
if (value == null || value === '') return fallback;
if (typeof value === 'string') return value;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
/**
* Postman's schema allows headers as strings in the format "Key: Value".
* This parses a single string header into an object.
*/
const parseStringHeader = (header) => {
const colonIndex = header.indexOf(':');
if (colonIndex === -1) return { key: header.trim(), value: '' };
return {
key: header.substring(0, colonIndex).trim(),
value: header.substring(colonIndex + 1).trim()
};
};
/**
* Postman's schema allows the header field to be:
* 1. An array of objects (most common)
* 2. An array with mixed string and object items
* 3. A single concatenated string (e.g. "Key1: Value1\r\nKey2: Value2")
* 4. null
*
* This normalizes all forms into an array of header objects.
*/
const normalizeHeaders = (headers) => {
if (!headers) return [];
if (typeof headers === 'string') {
return headers.split(/\r?\n/).filter(Boolean).map(parseStringHeader);
}
if (!Array.isArray(headers)) return [];
return headers.map((header) => {
if (typeof header === 'string') return parseStringHeader(header);
return header;
});
};
const convertV21Auth = (array) => {
return array.reduce((accumulator, currentValue) => {
accumulator[currentValue.key] = currentValue.value;
@@ -159,7 +208,7 @@ const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.filter((v) => !(v.key == null && v.value == null)).map((v) => ({
uid: uuid(),
name: (v.key ?? '').replace(invalidVariableCharacterRegex, '_'),
value: v.value ?? '',
value: v.value == null ? '' : typeof v.value === 'string' ? v.value : JSON.stringify(v.value),
enabled: true
}));
@@ -194,40 +243,40 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
switch (auth.type) {
case AUTH_TYPES.BASIC:
requestObject.auth.basic = {
username: authValues.username || '',
password: authValues.password || ''
username: ensureString(authValues.username),
password: ensureString(authValues.password)
};
break;
case AUTH_TYPES.BEARER:
requestObject.auth.bearer = {
token: authValues.token || ''
token: ensureString(authValues.token)
};
break;
case AUTH_TYPES.AWSV4:
requestObject.auth.awsv4 = {
accessKeyId: authValues.accessKey || '',
secretAccessKey: authValues.secretKey || '',
sessionToken: authValues.sessionToken || '',
service: authValues.service || '',
region: authValues.region || '',
accessKeyId: ensureString(authValues.accessKey),
secretAccessKey: ensureString(authValues.secretKey),
sessionToken: ensureString(authValues.sessionToken),
service: ensureString(authValues.service),
region: ensureString(authValues.region),
profileName: ''
};
break;
case AUTH_TYPES.APIKEY:
requestObject.auth.apikey = {
key: authValues.key || '',
value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
key: ensureString(authValues.key),
value: ensureString(authValues.value),
placement: 'header' // By default we are placing the apikey values in headers!
};
break;
case AUTH_TYPES.DIGEST:
requestObject.auth.digest = {
username: authValues.username || '',
password: authValues.password || ''
username: ensureString(authValues.username),
password: ensureString(authValues.password)
};
break;
case AUTH_TYPES.OAUTH2:
const findValueUsingKey = (key) => authValues[key] || '';
case AUTH_TYPES.OAUTH2: {
const findValueUsingKey = (key) => ensureString(authValues[key]);
// Maps Postman's grant_type to the Bruno's grantType string expected in the target object
const oauth2GrantTypeMaps = {
@@ -286,6 +335,7 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
break;
}
break;
}
default:
requestObject.auth.mode = AUTH_TYPES.NONE;
console.warn('Unexpected auth.type:', auth.type, '- Mode set, but no specific config generated.');
@@ -470,12 +520,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
const value = isFile
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: param.key ?? '',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
@@ -490,8 +540,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (param.key == null && param.value == null) return;
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
@@ -522,12 +572,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
each(normalizeHeaders(i.request.header), (header) => {
if (header.key == null && header.value == null) return;
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
@@ -542,8 +592,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
@@ -558,8 +608,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
@@ -611,13 +661,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
};
// Convert original request headers
if (originalRequest.header && Array.isArray(originalRequest.header)) {
originalRequest.header.forEach((header) => {
if (originalRequest.header) {
normalizeHeaders(originalRequest.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.request.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
@@ -632,8 +682,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
example.request.params.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
@@ -646,8 +696,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (!param.key) return;
example.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
@@ -666,12 +716,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
const value = isFile
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
example.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: param.key ?? '',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
@@ -686,8 +736,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (param.key == null && param.value == null) return;
example.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
@@ -712,13 +762,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
// Convert response headers
if (response.header && Array.isArray(response.header)) {
response.header.forEach((header) => {
if (response.header) {
normalizeHeaders(response.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.response.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: true
});
@@ -736,8 +786,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const searchLanguageByHeader = (headers) => {
let contentType;
each(headers, (header) => {
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
each(normalizeHeaders(headers), (header) => {
if (header.key?.toLowerCase() === 'content-type' && !header.disabled) {
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
contentType = 'json';
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
@@ -750,14 +800,14 @@ const searchLanguageByHeader = (headers) => {
};
const getBodyTypeFromContentTypeHeader = (headers) => {
// Check if headers is null, undefined, or not an array
if (!headers || !Array.isArray(headers)) {
const normalizedHeaders = normalizeHeaders(headers);
if (!normalizedHeaders.length) {
return 'text';
}
const contentTypeHeader = headers.find((header) => header.key.toLowerCase() === 'content-type');
if (contentTypeHeader) {
const contentType = contentTypeHeader.value?.toLowerCase();
const contentTypeHeader = normalizedHeaders.find((header) => header.key?.toLowerCase() === 'content-type');
if (contentTypeHeader && typeof contentTypeHeader.value === 'string') {
const contentType = contentTypeHeader.value.toLowerCase();
if (contentType?.includes('application/json')) {
return 'json';
} else if (contentType?.includes('application/xml') || contentType?.includes('text/xml')) {

View File

@@ -238,6 +238,43 @@ describe('postman-collection', () => {
]);
});
it('should convert non-string variable values to strings', async () => {
const collectionWithNonStringVars = {
info: {
name: 'Non-String Variable Demo',
_postman_id: 'abcd1234-5678-90ef-ghij-1234567890ab',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
variable: [
{ key: 'timeout', value: 5000 },
{ key: 'enabled', value: true },
{ key: 'user', value: { id: 1, name: 'Alice' } }
],
item: [
{
name: 'Sample Request',
request: {
method: 'GET',
url: {
raw: 'https://postman-echo.com/get',
protocol: 'https',
host: ['postman-echo', 'com'],
path: ['get']
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNonStringVars);
const vars = brunoCollection.root.request.vars.req;
expect(vars).toHaveLength(3);
expect(vars[0]).toMatchObject({ name: 'timeout', value: '5000' });
expect(vars[1]).toMatchObject({ name: 'enabled', value: 'true' });
expect(vars[2]).toMatchObject({ name: 'user', value: '{"id":1,"name":"Alice"}' });
});
it('should handle empty variables', async () => {
const collectionWithEmptyVars = {
info: {
@@ -769,6 +806,337 @@ describe('postman-collection', () => {
expect(params[2].value).toBe('');
expect(params[2].type).toBe('query');
});
it('should convert numeric values to strings in headers, params, and body fields', async () => {
const collectionWithNumericValues = {
info: {
_postman_id: 'test-numeric-values',
name: 'collection with numeric values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric values',
request: {
method: 'POST',
header: [
{ key: 'X-Account-Id', value: 0 },
{ key: 'X-Retry-Count', value: 3 }
],
url: {
raw: 'https://example.com/api/:accountId',
protocol: 'https',
host: ['example', 'com'],
path: ['api', ':accountId'],
query: [
{ key: 'limit', value: 100 },
{ key: 'offset', value: 0 }
],
variable: [
{ key: 'accountId', value: 0 }
]
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: 'timeout', value: 5000 }
]
}
}
},
{
name: 'request with numeric multipart form values',
request: {
method: 'POST',
header: [],
url: { raw: 'https://example.com/upload' },
body: {
mode: 'formdata',
formdata: [
{ key: 'retries', value: 3, type: 'text' },
{ key: 'priority', value: 0, type: 'text' }
]
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericValues);
const item = brunoCollection.items[0];
// Headers should have string values
expect(item.request.headers[0].value).toBe('0');
expect(item.request.headers[1].value).toBe('3');
// Query params should have string values
const queryParams = item.request.params.filter((p) => p.type === 'query');
expect(queryParams[0].value).toBe('100');
expect(queryParams[1].value).toBe('0');
// Path params should have string values
const pathParams = item.request.params.filter((p) => p.type === 'path');
expect(pathParams[0].value).toBe('0');
// Form URL-encoded should have string values
expect(item.request.body.formUrlEncoded[0].value).toBe('5000');
// Multipart form should have string values
const multipartItem = brunoCollection.items[1];
expect(multipartItem.request.body.multipartForm[0].value).toBe('3');
expect(multipartItem.request.body.multipartForm[1].value).toBe('0');
});
it('should convert numeric values to strings in example request and response fields', async () => {
const collectionWithNumericExamples = {
info: {
_postman_id: 'test-numeric-examples',
name: 'collection with numeric example values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric example',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' }
},
response: [
{
name: 'Example with numerics',
originalRequest: {
method: 'GET',
header: [
{ key: 'X-Account-Id', value: 42 }
],
url: {
raw: 'https://example.com/api/:id?page=1',
protocol: 'https',
host: ['example', 'com'],
path: ['api', ':id'],
query: [
{ key: 'page', value: 1 }
],
variable: [
{ key: 'id', value: 99 }
]
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: 'retries', value: 3 }
]
}
},
status: 'OK',
code: 200,
header: [
{ key: 'X-RateLimit-Remaining', value: 0 }
],
body: '{"ok": true}'
}
]
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericExamples);
const example = brunoCollection.items[0].examples[0];
// Example request headers
expect(example.request.headers[0].value).toBe('42');
// Example request query params
const queryParams = example.request.params.filter((p) => p.type === 'query');
expect(queryParams[0].value).toBe('1');
// Example request path params
const pathParams = example.request.params.filter((p) => p.type === 'path');
expect(pathParams[0].value).toBe('99');
// Example request form URL-encoded
expect(example.request.body.formUrlEncoded[0].value).toBe('3');
// Example response headers
expect(example.response.headers[0].value).toBe('0');
});
it('should convert numeric auth values to strings (array-backed v2.1 format)', async () => {
const collectionWithNumericAuth = {
info: {
_postman_id: 'test-numeric-auth',
name: 'collection with numeric auth values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric bearer token',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'bearer',
bearer: [
{ key: 'token', value: 123 }
]
}
}
},
{
name: 'request with numeric apikey values',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'apikey',
apikey: [
{ key: 'key', value: 456 },
{ key: 'value', value: 789 }
]
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericAuth);
// Bearer token should be stringified
expect(brunoCollection.items[0].request.auth.mode).toBe('bearer');
expect(brunoCollection.items[0].request.auth.bearer.token).toBe('123');
// API key fields should be stringified
expect(brunoCollection.items[1].request.auth.mode).toBe('apikey');
expect(brunoCollection.items[1].request.auth.apikey.key).toBe('456');
expect(brunoCollection.items[1].request.auth.apikey.value).toBe('789');
});
it('should convert numeric auth values to strings (object-backed format)', async () => {
const collectionWithObjectAuth = {
info: {
_postman_id: 'test-object-auth',
name: 'collection with object-backed auth',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with object-backed basic auth',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'basic',
basic: {
username: 12345,
password: 67890
}
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithObjectAuth);
expect(brunoCollection.items[0].request.auth.mode).toBe('basic');
expect(brunoCollection.items[0].request.auth.basic.username).toBe('12345');
expect(brunoCollection.items[0].request.auth.basic.password).toBe('67890');
});
it('should parse string headers in request header arrays', async () => {
const collectionWithStringHeaders = {
info: {
_postman_id: 'test-string-headers',
name: 'collection with string headers',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with string headers',
request: {
method: 'GET',
header: [
'Content-Type: application/json',
{ key: 'X-Custom', value: 'test' },
'Authorization: Bearer token123'
],
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithStringHeaders);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(3);
expect(headers[0].name).toBe('Content-Type');
expect(headers[0].value).toBe('application/json');
expect(headers[1].name).toBe('X-Custom');
expect(headers[1].value).toBe('test');
expect(headers[2].name).toBe('Authorization');
expect(headers[2].value).toBe('Bearer token123');
});
it('should parse a single concatenated string as the header field', async () => {
const collectionWithConcatenatedHeaders = {
info: {
_postman_id: 'test-concat-headers',
name: 'collection with concatenated header string',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with concatenated header',
request: {
method: 'GET',
header: 'Content-Type: application/json\r\nHost: example.com',
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithConcatenatedHeaders);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(2);
expect(headers[0].name).toBe('Content-Type');
expect(headers[0].value).toBe('application/json');
expect(headers[1].name).toBe('Host');
expect(headers[1].value).toBe('example.com');
});
it('should handle string headers with no value', async () => {
const collectionWithNoValueHeader = {
info: {
_postman_id: 'test-no-value-header',
name: 'collection with no-value string header',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with no-value header',
request: {
method: 'GET',
header: ['X-No-Value'],
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNoValueHeader);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(1);
expect(headers[0].name).toBe('X-No-Value');
expect(headers[0].value).toBe('');
});
});
// Simple Collection (postman)

View File

@@ -44,8 +44,8 @@
"about-window": "^1.15.2",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
@@ -58,7 +58,7 @@
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
"extract-zip": "^2.0.1",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
"hexy": "^0.3.5",

View File

@@ -141,6 +141,16 @@ class ApiSpecWatcher {
delete this.watcherWorkspaces[watchPath];
}
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
this.watcherWorkspaces = {};
}
}
module.exports = ApiSpecWatcher;

View File

@@ -552,58 +552,84 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const unlink = (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher unlink: ${pathname}`);
if (isEnvironmentsFolder(pathname, collectionPath)) {
return unlinkEnvironmentFile(win, pathname, collectionUid);
}
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
try {
if (!fs.existsSync(collectionPath)) {
return;
}
console.log(`watcher unlink: ${pathname}`);
const file = {
meta: {
collectionUid,
pathname,
name: basename
if (isEnvironmentsFolder(pathname, collectionPath)) {
return unlinkEnvironmentFile(win, pathname, collectionUid);
}
let format;
try {
format = getCollectionFormat(collectionPath);
} catch (error) {
console.error(`Error getting collection format for: ${collectionPath}`, error);
return;
}
if (hasRequestExtension(pathname, format)) {
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
return;
}
};
win.webContents.send('main:collection-tree-updated', 'unlink', file);
const file = {
meta: {
collectionUid,
pathname,
name: basename
}
};
win.webContents.send('main:collection-tree-updated', 'unlink', file);
}
} catch (err) {
console.error(`Error processing unlink event for: ${pathname}`, err);
}
};
const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (path.normalize(pathname) === path.normalize(envDirectory)) {
return;
}
const format = getCollectionFormat(collectionPath);
const folderFilePath = path.join(pathname, `folder.${format}`);
let name = path.basename(pathname);
if (fs.existsSync(folderFilePath)) {
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
let folderData = await parseFolder(folderFileContent, { format });
name = folderData?.meta?.name || name;
}
const directory = {
meta: {
collectionUid,
pathname,
name
try {
if (!fs.existsSync(collectionPath)) {
return;
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
const envDirectory = path.join(collectionPath, 'environments');
if (path.normalize(pathname) === path.normalize(envDirectory)) {
return;
}
let format;
try {
format = getCollectionFormat(collectionPath);
} catch (error) {
console.error(`Error getting collection format for: ${collectionPath}`, error);
return;
}
const folderFilePath = path.join(pathname, `folder.${format}`);
let name = path.basename(pathname);
if (fs.existsSync(folderFilePath)) {
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
let folderData = await parseFolder(folderFileContent, { format });
name = folderData?.meta?.name || name;
}
const directory = {
meta: {
collectionUid,
pathname,
name
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
} catch (err) {
console.error(`Error processing unlinkDir event for: ${pathname}`, err);
}
};
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
@@ -932,6 +958,15 @@ class CollectionWatcher {
.filter(([path, watcher]) => !!watcher)
.map(([path, _watcher]) => path);
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
}
}
const collectionWatcher = new CollectionWatcher();

View File

@@ -27,6 +27,7 @@ const template = [
},
{
label: 'Preferences',
accelerator: 'CommandOrControl+,',
click() {
ipcMain.emit('main:open-preferences');
}
@@ -88,7 +89,7 @@ const template = [
},
{
role: 'window',
submenu: [{ role: 'minimize' }, { role: 'close' }]
submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }]
},
{
role: 'help',

View File

@@ -224,6 +224,24 @@ class WorkspaceWatcher {
hasWatcher(workspacePath) {
return Boolean(this.watchers[workspacePath]);
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
for (const [watchPath, watcher] of Object.entries(this.environmentWatchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.environmentWatchers = {};
dotEnvWatcher.closeAll();
}
}
module.exports = WorkspaceWatcher;

View File

@@ -3,7 +3,7 @@ const path = require('path');
const { execSync } = require('node:child_process');
const isDev = require('electron-is-dev');
const os = require('os');
const { initializeShellEnv } = require('@usebruno/requests');
const { initializeShellEnv, waitForShellEnv } = require('./store/shell-env-state');
const { percentageToZoomLevel } = require('@usebruno/common');
if (isDev) {
@@ -122,6 +122,12 @@ const focusMainWindow = () => {
}
};
const closeAllWatchers = () => {
collectionWatcher.closeAllWatchers();
workspaceWatcher.closeAllWatchers();
apiSpecWatcher.closeAllWatchers();
};
// Parse protocol URL from command line arguments (if any)
appProtocolUrl = getAppProtocolUrlFromArgv(process.argv);
@@ -175,8 +181,7 @@ if (useSingleInstance && !gotTheLock) {
// Prepare the renderer once the app is ready
app.on('ready', async () => {
// Ensure shell environment is loaded before any operations that need it
await initializeShellEnv();
initializeShellEnv();
if (isDev) {
const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
@@ -197,9 +202,18 @@ app.on('ready', async () => {
// Initialize system proxy cache early (non-blocking)
const { fetchSystemProxy } = require('./store/system-proxy');
fetchSystemProxy().catch((err) => {
console.warn('Failed to initialize system proxy cache:', err);
});
// Note: irrespective of the state of the shell,
// try to fetch the system proxy information
waitForShellEnv()
.catch((err) => {
console.warn('Shell env init failed:', err);
})
.finally(() => {
fetchSystemProxy().catch((err) => {
console.warn('Failed to initialize system proxy cache:', err);
});
});
Menu.setApplicationMenu(menu);
const { maximized, x, y, width, height } = loadWindowState();
@@ -216,8 +230,7 @@ app.on('ready', async () => {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webviewTag: true,
zoomFactor: 1.0
webviewTag: true
},
title: 'Bruno',
icon: path.join(__dirname, 'about/256x256.png'),
@@ -247,29 +260,8 @@ app.on('ready', async () => {
}
});
// Handle zoom shortcuts
ipcMain.on('main:zoom-in', () => {
if (mainWindow && mainWindow.webContents) {
const currentZoom = mainWindow.webContents.getZoomLevel();
mainWindow.webContents.setZoomLevel(currentZoom + 0.5);
}
});
ipcMain.on('main:zoom-out', () => {
if (mainWindow && mainWindow.webContents) {
const currentZoom = mainWindow.webContents.getZoomLevel();
mainWindow.webContents.setZoomLevel(currentZoom - 0.5);
}
});
ipcMain.on('main:zoom-reset', () => {
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.setZoomLevel(0);
}
});
ipcMain.on('renderer:window-close', () => {
// if (!isWindows && !isLinux) return;
if (!isWindows && !isLinux) return;
mainWindow.close();
});
@@ -477,6 +469,7 @@ app.on('ready', async () => {
// Quit the app once all windows are closed
app.on('before-quit', () => {
closeAllWatchers();
// Release single instance lock to allow other instances to take over
if (useSingleInstance && gotTheLock) {
app.releaseSingleInstanceLock();
@@ -505,6 +498,14 @@ app.on('open-file', (event, path) => {
openCollection(mainWindow, collectionWatcher, path);
});
// Register the global shortcuts
app.on('browser-window-focus', () => {
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
globalShortcut.register('Ctrl+=', () => {
incrementZoomAndPersist(10);
});
});
// Disable global shortcuts when not focused
app.on('browser-window-blur', () => {
globalShortcut.unregisterAll();

View File

@@ -1271,11 +1271,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
// Save OpenAPI spec file for sync support
if (rawOpenAPISpec && brunoConfig.openapi?.length) {
const importSourceUrl = brunoConfig.openapi[0].sourceUrl;
const specContent = typeof rawOpenAPISpec === 'string'
? rawOpenAPISpec
: JSON.stringify(rawOpenAPISpec, null, 2);
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl: importSourceUrl });
await saveSpecAndUpdateMetadata({ collectionPath, specContent });
}
const { size, filesCount } = await getCollectionStats(collectionPath);

View File

@@ -35,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -594,17 +594,22 @@ const registerNetworkIpc = (mainWindow) => {
// if `data` is of string type - return as-is (assumes already encoded)
}
if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) {
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
if (!isFormData(request.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);
request.data = form;
if (contentTypeHeader !== 'multipart/form-data') {
if (contentType !== 'multipart/form-data') {
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
const formHeaders = form.getHeaders();
const ct = contentTypeHeader;
formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`;
const existingBoundary = extractBoundaryFromContentType(contentType);
if (existingBoundary) {
formHeaders['content-type'] = contentType;
} else {
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
}
form.getHeaders = function () {
return formHeaders;
};

View File

@@ -12,7 +12,7 @@ const {
stringifyFolder
} = require('@usebruno/filestore');
const { openApiToBruno } = require('@usebruno/converters');
const { writeFile, sanitizeName, getCollectionFormat } = require('../utils/filesystem');
const { writeFile, sanitizeName, getCollectionFormat, posixifyPath } = require('../utils/filesystem');
const { getEnvVars } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getCertsAndProxyConfig } = require('./network/cert-utils');
@@ -84,6 +84,11 @@ const isValidHttpUrl = (urlString) => {
const isLocalFilePath = (str) => !isValidHttpUrl(str) && typeof str === 'string' && str.length > 0;
const resolveSourceUrl = (collectionPath, sourceUrl) => {
if (!sourceUrl || isValidHttpUrl(sourceUrl)) return sourceUrl;
return path.resolve(collectionPath, sourceUrl);
};
/**
* Get the directory where OpenAPI spec files are stored in AppData.
*/
@@ -127,8 +132,8 @@ const getSpecEntriesForCollection = (collectionPath) => {
/**
* Get the spec entry for a specific sourceUrl within a collection.
*/
const getSpecEntryForUrl = (collectionPath, sourceUrl) => {
return getSpecEntriesForCollection(collectionPath).find((e) => e.sourceUrl === sourceUrl) || null;
const getSpecEntryForUrl = (collectionPath) => {
return getSpecEntriesForCollection(collectionPath)[0] || null;
};
/**
@@ -260,6 +265,14 @@ const loadBrunoConfig = (collectionPath) => {
brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8'));
}
// Resolve relative openapi sourceUrls to absolute so all callers get consistent paths
if (Array.isArray(brunoConfig?.openapi)) {
brunoConfig.openapi = brunoConfig.openapi.map((entry) => ({
...entry,
sourceUrl: resolveSourceUrl(collectionPath, entry.sourceUrl)
}));
}
return { format, brunoConfig, collectionRoot };
};
@@ -267,12 +280,23 @@ const loadBrunoConfig = (collectionPath) => {
* Save bruno config to disk (bruno.json or opencollection.yml).
*/
const saveBrunoConfig = async (collectionPath, format, brunoConfig, collectionRoot) => {
// Convert absolute openapi sourceUrls back to collection-relative for git-shareability
const configToSave = { ...brunoConfig };
if (Array.isArray(configToSave?.openapi)) {
configToSave.openapi = configToSave.openapi.map((entry) => ({
...entry,
sourceUrl: (entry.sourceUrl && !isValidHttpUrl(entry.sourceUrl))
? posixifyPath(path.relative(collectionPath, entry.sourceUrl))
: entry.sourceUrl
}));
}
if (format === 'yml') {
const content = await stringifyCollection(collectionRoot, brunoConfig, { format });
const content = await stringifyCollection(collectionRoot, configToSave, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), content);
} else {
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
await writeFile(brunoJsonPath, JSON.stringify(brunoConfig, null, 2));
await writeFile(brunoJsonPath, JSON.stringify(configToSave, null, 2));
}
};
@@ -346,9 +370,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
const specsDir = getSpecsDir();
await fsExtra.ensureDir(specsDir);
const resolvedUrl = resolveSourceUrl(collectionPath, sourceUrl);
const meta = loadSpecMetadata();
const entries = meta[collectionPath] || [];
const existingEntry = entries.find((e) => e.sourceUrl === sourceUrl);
const existingEntry = (meta[collectionPath] || [])[0];
let filename;
if (existingEntry) {
@@ -358,10 +382,12 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
// Generate a new UUID filename based on content type
const ext = isYamlContent(content) ? 'yaml' : 'json';
filename = `${crypto.randomUUID()}.${ext}`;
meta[collectionPath] = [...entries, { filename, sourceUrl }];
saveSpecMetadata(meta);
}
// Always replace with a single entry (one spec per collection for now)
meta[collectionPath] = [{ filename, sourceUrl: resolvedUrl }];
saveSpecMetadata(meta);
await writeFile(path.join(specsDir, filename), content);
};
@@ -369,8 +395,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
* Save an OpenAPI spec file and update sync metadata (lastSyncDate, specHash) in brunoConfig.
* Shared by both the IPC handler (connect flow) and the import flow.
*/
const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUrl }) => {
const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent }) => {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
@@ -383,14 +410,9 @@ const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUr
const specHash = generateSpecHash(parsedSpec);
const lastSyncDate = new Date().toISOString();
const openapi = brunoConfig.openapi || [];
const idx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
if (idx !== -1) {
openapi[idx] = { ...openapi[idx], lastSyncDate, specHash };
} else {
openapi.push({ sourceUrl, lastSyncDate, specHash });
}
brunoConfig.openapi = openapi;
if (brunoConfig.openapi?.[0]) {
brunoConfig.openapi[0] = { ...brunoConfig.openapi[0], lastSyncDate, specHash };
};
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
};
@@ -417,7 +439,7 @@ const cleanupSpecFilesForCollection = (collectionPath) => {
* Only preserves the user's enabled state; values come from the spec.
*/
const mergeWithUserValues = (specItems, existingItems) => {
return specItems?.map((specItem) => {
return (specItems || []).map((specItem) => {
const existing = (existingItems || []).find(
(e) => e.name === specItem.name && e.value === specItem.value
);
@@ -440,7 +462,12 @@ const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } =
return {
...existingRequest,
request: {
...specItem.request,
...existingRequest.request,
url: specItem.request.url,
method: specItem.request.method,
body: specItem.request.body,
auth: specItem.request.auth,
docs: specItem.request.docs,
params: mergedParams || [],
headers: mergedHeaders || []
}
@@ -509,13 +536,146 @@ const buildSpecItemsMap = (collectionItems) => {
return map;
};
/**
* Recursively extracts all key paths from a parsed JSON value (dot-notation).
* Used to compare JSON body structure/schema without comparing values.
*/
const extractJsonKeys = (obj, prefix = '') => {
const keys = [];
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.push(fullKey);
keys.push(...extractJsonKeys(obj[key], fullKey));
}
} else if (Array.isArray(obj) && obj.length > 0) {
// Only inspect first element (spec arrays always have one template item)
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
}
return keys;
};
/**
* Compare two Bruno-format requests field-by-field.
* Returns { hasDiff, changes } where changes is an array of human-readable strings.
*/
const compareRequestFields = (specRequest, actualRequest) => {
// Compare parameters by name:type pairs (catches query<->path type changes)
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
// Compare headers (by name)
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
// Check for differences
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
// Check body mode difference
const specBodyMode = specRequest.body?.mode || 'none';
const actualBodyMode = actualRequest.body?.mode || 'none';
const bodyDiff = specBodyMode !== actualBodyMode;
// Check auth mode difference
const specAuthMode = specRequest.auth?.mode || 'none';
const actualAuthMode = actualRequest.auth?.mode || 'none';
const authDiff = specAuthMode !== actualAuthMode;
// Check auth config differences when auth modes match
let authConfigDiff = false;
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
if (specAuthMode === 'apikey') {
const specApikey = specRequest.auth?.apikey || {};
const actualApikey = actualRequest.auth?.apikey || {};
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
} else if (specAuthMode === 'oauth2') {
const specOauth2 = specRequest.auth?.oauth2 || {};
const actualOauth2 = actualRequest.auth?.oauth2 || {};
const grantType = specOauth2.grantType || actualOauth2.grantType;
const commonFields = ['grantType', 'scope'];
const grantTypeFields = {
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
implicit: [...commonFields, 'authorizationUrl'],
password: [...commonFields, 'accessTokenUrl'],
client_credentials: [...commonFields, 'accessTokenUrl']
};
const fields = grantTypeFields[grantType] || commonFields;
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
}
}
// Check form field names when body modes match and mode is form-based
let formFieldsDiff = false;
let specFormFieldNames = [];
let actualFormFieldNames = [];
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
if (specBodyMode === 'multipartForm') {
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
} else {
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
}
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
}
// Check JSON body structure when both sides use json mode
let jsonBodyDiff = false;
if (!bodyDiff && specBodyMode === 'json') {
try {
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
if (specJson !== null && actualJson !== null) {
const specKeys = extractJsonKeys(specJson).sort();
const actualKeys = extractJsonKeys(actualJson).sort();
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
} else if ((specJson === null) !== (actualJson === null)) {
jsonBodyDiff = true;
}
} catch (e) {
// Malformed JSON — skip structural comparison
}
}
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
const changes = [];
if (hasDiff) {
if (paramsDiff) {
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
if (addedParams.length) changes.push(`+${addedParams.length} params`);
if (removedParams.length) changes.push(`-${removedParams.length} params`);
}
if (headersDiff) {
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
}
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
if (authConfigDiff) changes.push('auth config');
if (formFieldsDiff) {
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
}
if (jsonBodyDiff) changes.push('body schema');
}
return { hasDiff, changes };
};
/**
* Load the stored spec for a collection and convert it to Bruno collection format.
* Throws if no stored spec file exists.
*/
const loadStoredSpecCollection = (collectionPath, brunoConfig) => {
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath, sourceUrl) : null;
const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath) : null;
const specPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
if (!specPath || !fs.existsSync(specPath)) {
@@ -549,127 +709,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
collectionUid, collectionPath, sourceUrl, environmentContext
}) => {
try {
// Get the title/name from the spec
const getSpecTitle = (spec) => {
return spec?.info?.title || null;
};
// Compare two OpenAPI specs by converting both to Bruno format and using field-level comparison.
// This ensures specDrift uses the same comparison sensitivity as collectionDrift/remoteDrift.
const compareSpecs = (oldSpec, newSpec, groupBy) => {
// Convert both specs to Bruno collection format
const oldBruno = oldSpec ? openApiToBruno(oldSpec, { groupBy }) : { items: [] };
const newBruno = newSpec ? openApiToBruno(newSpec, { groupBy }) : { items: [] };
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
const normalizePath = (pathStr) => {
return pathStr
.replace(/{([^}]+)}/g, ':$1')
.replace(/\/+/g, '/')
.replace(/\/$/, '');
};
const extractEndpoints = (spec) => {
const endpoints = [];
if (!spec || !spec.paths) return endpoints;
// Get base URL from servers
const baseUrl = spec.servers?.[0]?.url || '';
Object.entries(spec.paths).forEach(([pathStr, methods]) => {
if (!methods || typeof methods !== 'object') return;
Object.entries(methods).forEach(([method, operation]) => {
if (!HTTP_METHODS.includes(method.toLowerCase())) return;
// Extract parameters
const parameters = operation?.parameters || [];
const pathParams = parameters.filter((p) => p.in === 'path');
const queryParams = parameters.filter((p) => p.in === 'query');
const headerParams = parameters.filter((p) => p.in === 'header');
// Extract request body
const requestBody = operation?.requestBody;
const bodyContent = requestBody?.content;
const bodySchema = bodyContent?.['application/json']?.schema
|| bodyContent?.['application/x-www-form-urlencoded']?.schema
|| bodyContent?.['multipart/form-data']?.schema;
const bodyExample = bodyContent?.['application/json']?.example
|| bodyContent?.['application/json']?.examples;
// Extract responses
const responses = operation?.responses || {};
endpoints.push({
id: `${method.toUpperCase()}:${normalizePath(pathStr)}`,
method: method.toUpperCase(),
path: pathStr,
normalizedPath: normalizePath(pathStr),
operationId: operation?.operationId || null,
summary: operation?.summary || null,
description: operation?.description || null,
tags: operation?.tags || [],
deprecated: operation?.deprecated || false,
// Detailed info for UI
details: {
parameters: {
path: pathParams,
query: queryParams,
header: headerParams
},
requestBody: requestBody ? {
required: requestBody.required || false,
contentType: Object.keys(bodyContent || {})[0] || null,
schema: bodySchema,
example: bodyExample
} : null,
responses: Object.entries(responses).map(([code, resp]) => ({
code,
description: resp.description,
schema: resp.content?.['application/json']?.schema
}))
},
// Hash for comparison (MD5 for quick change detection)
_hash: crypto.createHash('md5').update(JSON.stringify({
parameters,
requestBody: operation?.requestBody,
responses: operation?.responses
})).digest('hex')
});
});
});
return endpoints;
};
const compareSpecs = (oldSpec, newSpec) => {
const oldEndpoints = extractEndpoints(oldSpec);
const newEndpoints = extractEndpoints(newSpec);
const oldEndpointMap = new Map(oldEndpoints.map((ep) => [ep.id, ep]));
const newEndpointMap = new Map(newEndpoints.map((ep) => [ep.id, ep]));
// Build endpoint maps keyed by METHOD:normalizedPath
const oldItems = buildSpecItemsMap(oldBruno.items || []);
const newItems = buildSpecItemsMap(newBruno.items || []);
const added = [];
const removed = [];
const modified = [];
const unchanged = [];
newEndpoints.forEach((endpoint) => {
if (!oldEndpointMap.has(endpoint.id)) {
added.push(endpoint);
for (const [id, newItem] of newItems) {
const colonIndex = id.indexOf(':');
const method = id.substring(0, colonIndex);
const urlPath = id.substring(colonIndex + 1);
if (!oldItems.has(id)) {
added.push({ id, method, path: urlPath, name: newItem.name });
} else {
const oldEndpoint = oldEndpointMap.get(endpoint.id);
// Check if endpoint was modified by comparing hashes
if (oldEndpoint._hash !== endpoint._hash) {
modified.push({
...endpoint,
oldEndpoint: oldEndpoint
});
const oldItem = oldItems.get(id);
const { hasDiff, changes } = compareRequestFields(oldItem.request, newItem.request);
if (hasDiff) {
modified.push({ id, method, path: urlPath, name: newItem.name, changes: changes.join(', ') });
} else {
unchanged.push(endpoint);
unchanged.push({ id, method, path: urlPath, name: newItem.name });
}
}
});
}
oldEndpoints.forEach((endpoint) => {
if (!newEndpointMap.has(endpoint.id)) {
removed.push(endpoint);
for (const [id] of oldItems) {
if (!newItems.has(id)) {
const colonIndex = id.indexOf(':');
const method = id.substring(0, colonIndex);
const urlPath = id.substring(colonIndex + 1);
const oldItem = oldItems.get(id);
removed.push({ id, method, path: urlPath, name: oldItem.name });
}
});
}
// Compare metadata (title, version, description)
const oldTitle = oldSpec?.info?.title || null;
@@ -706,7 +788,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
};
};
const specEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
const specEntry = getSpecEntryForUrl(collectionPath);
const storedSpecPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
let storedSpec = null;
@@ -746,8 +828,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Check for title/name changes
const storedTitle = getSpecTitle(storedSpec);
const newTitle = getSpecTitle(newSpec);
const storedTitle = storedSpec?.info?.title || null;
const newTitle = newSpec?.info?.title || null;
const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle;
// Generate hashes for quick change detection
@@ -755,7 +837,16 @@ const registerOpenAPISyncIpc = (mainWindow) => {
const remoteSpecHash = generateSpecHash(newSpec);
const hasRemoteChanges = storedSpecHash !== remoteSpecHash;
const diff = compareSpecs(storedSpec, newSpec);
// Read groupBy from brunoConfig for consistent spec conversion
let groupBy = 'tags';
try {
const { brunoConfig } = loadBrunoConfig(collectionPath);
groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
} catch (e) {
// Default to 'tags' if brunoConfig is not available
}
const diff = compareSpecs(storedSpec, newSpec, groupBy);
// Detect remote spec format and determine correct filename
const remoteIsYaml = isYamlContent(newSpecContent);
@@ -801,36 +892,14 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
});
// Recursively extracts all key paths from a parsed JSON value (dot-notation).
// Used to compare JSON body structure/schema without comparing values.
const extractJsonKeys = (obj, prefix = '') => {
const keys = [];
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.push(fullKey);
keys.push(...extractJsonKeys(obj[key], fullKey));
}
} else if (Array.isArray(obj) && obj.length > 0) {
// Only inspect first element (spec arrays always have one template item)
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
}
return keys;
};
// Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => {
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, compareSpec }) => {
try {
// Use passed brunoConfig if available, otherwise read from disk
let brunoConfig;
if (passedBrunoConfig) {
brunoConfig = passedBrunoConfig;
} else {
try {
({ brunoConfig } = loadBrunoConfig(collectionPath));
} catch (err) {
return { error: err.message };
}
try {
({ brunoConfig } = loadBrunoConfig(collectionPath));
} catch (err) {
return { error: err.message };
}
// Load spec to compare against — use compareSpec if provided, otherwise read stored spec from disk
@@ -841,7 +910,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
specToCompare = compareSpec;
} else {
const driftSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath, driftSourceUrl) : null;
const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath) : null;
const storedSpecPath = driftSpecEntry ? path.join(getSpecsDir(), driftSpecEntry.filename) : null;
if (!storedSpecPath || !fs.existsSync(storedSpecPath)) {
@@ -936,113 +1005,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
} else {
// Compare key fields to detect drift
const specRequest = specItem.request;
// Compare parameters by name:type pairs (catches query<->path type changes)
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
// Compare headers (by name)
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
// Check for differences
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
// Check body mode difference
const specBodyMode = specRequest.body?.mode || 'none';
const actualBodyMode = actualRequest.body?.mode || 'none';
const bodyDiff = specBodyMode !== actualBodyMode;
// Check auth mode difference
const specAuthMode = specRequest.auth?.mode || 'none';
const actualAuthMode = actualRequest.auth?.mode || 'none';
const authDiff = specAuthMode !== actualAuthMode;
// Check auth config differences when auth modes match
let authConfigDiff = false;
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
if (specAuthMode === 'apikey') {
const specApikey = specRequest.auth?.apikey || {};
const actualApikey = actualRequest.auth?.apikey || {};
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
} else if (specAuthMode === 'oauth2') {
const specOauth2 = specRequest.auth?.oauth2 || {};
const actualOauth2 = actualRequest.auth?.oauth2 || {};
const grantType = specOauth2.grantType || actualOauth2.grantType;
const commonFields = ['grantType', 'scope'];
const grantTypeFields = {
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
implicit: [...commonFields, 'authorizationUrl'],
password: [...commonFields, 'accessTokenUrl'],
client_credentials: [...commonFields, 'accessTokenUrl']
};
const fields = grantTypeFields[grantType] || commonFields;
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
}
}
// Check form field names when body modes match and mode is form-based
let formFieldsDiff = false;
let specFormFieldNames = [];
let actualFormFieldNames = [];
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
if (specBodyMode === 'multipartForm') {
// For multipartForm, compare name:type pairs to catch text<->file changes
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
} else {
// For formUrlEncoded, all fields are text — compare by name only
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
}
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
}
// Check JSON body structure when both sides use json mode
let jsonBodyDiff = false;
if (!bodyDiff && specBodyMode === 'json') {
try {
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
if (specJson !== null && actualJson !== null) {
const specKeys = extractJsonKeys(specJson).sort();
const actualKeys = extractJsonKeys(actualJson).sort();
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
} else if ((specJson === null) !== (actualJson === null)) {
jsonBodyDiff = true;
}
} catch (e) {
// Malformed JSON — skip structural comparison
}
}
if (paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff) {
const changes = [];
if (paramsDiff) {
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
if (addedParams.length) changes.push(`+${addedParams.length} params`);
if (removedParams.length) changes.push(`-${removedParams.length} params`);
}
if (headersDiff) {
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
}
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
if (authConfigDiff) changes.push('auth config');
if (formFieldsDiff) {
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
}
if (jsonBodyDiff) changes.push('body schema');
const { hasDiff, changes } = compareRequestFields(specItem.request, actualRequest);
if (hasDiff) {
result.modified.push({
id,
method,
@@ -1114,7 +1079,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
let specToUse = newSpec;
if (!specToUse) {
const diffSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath, diffSourceUrl) : null;
const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath) : null;
const storedSpecPath = diffSpecEntry ? path.join(getSpecsDir(), diffSpecEntry.filename) : null;
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
const content = fs.readFileSync(storedSpecPath, 'utf8');
@@ -1192,9 +1157,10 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Sync modes: 'spec-only' | 'reset' | 'sync' (default)
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, sourceUrl, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
// Mode: spec-only - Just save the spec, don't touch collection
if (mode === 'spec-only') {
@@ -1204,16 +1170,13 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Update sync metadata
const openapi = brunoConfig.openapi || [];
const specOnlyIdx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
if (specOnlyIdx !== -1) {
openapi[specOnlyIdx] = {
...openapi[specOnlyIdx],
if (brunoConfig.openapi?.[0]) {
brunoConfig.openapi[0] = {
...brunoConfig.openapi[0],
lastSyncDate: new Date().toISOString(),
specHash: generateSpecHash(diff.newSpec)
};
}
brunoConfig.openapi = openapi;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
@@ -1222,8 +1185,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
// Mode: reset - Save spec and reset all endpoints to spec (preserve tests/scripts)
if (mode === 'reset' && diff.newSpec) {
const openapiEntryReset = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
const groupBy = openapiEntryReset?.groupBy || 'tags';
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
const newCollection = openApiToBruno(diff.newSpec, { groupBy });
// Build map of spec items by endpoint ID
@@ -1288,16 +1250,13 @@ const registerOpenAPISyncIpc = (mainWindow) => {
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
// Update sync metadata
const openapiReset = brunoConfig.openapi || [];
const resetIdx = openapiReset.findIndex((e) => e.sourceUrl === sourceUrl);
if (resetIdx !== -1) {
openapiReset[resetIdx] = {
...openapiReset[resetIdx],
if (brunoConfig.openapi?.[0]) {
brunoConfig.openapi[0] = {
...brunoConfig.openapi[0],
lastSyncDate: new Date().toISOString(),
specHash: generateSpecHash(diff.newSpec)
};
}
brunoConfig.openapi = openapiReset;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
@@ -1305,8 +1264,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Mode: sync (default) — compute shared values once
const syncEntry = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
const groupBy = syncEntry?.groupBy || 'tags';
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
let newCollection;
if (diff.newSpec) {
try {
@@ -1316,35 +1274,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
}
if (addNewRequests && diff.added?.length > 0 && newCollection) {
for (const endpoint of diff.added) {
const normalizedPath = normalizeUrlPath(endpoint.path);
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
const newItem = result?.item;
if (newItem) {
// Check if endpoint already exists in collection (prevents overwriting user customizations)
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
} else {
// Truly new — create file in the appropriate folder
let targetFolder = collectionPath;
if (result.folderName && groupBy === 'tags') {
targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
}
const requestContent = await stringifyRequestViaWorker(newItem, { format });
const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
}
}
}
}
// Remove endpoints before adding new ones to avoid filename collisions
// (e.g., when a path is renamed but the summary stays the same, both generate the same filename)
if (removeDeletedRequests && diff.removed?.length > 0) {
const findAndRemoveRequest = (dirPath) => {
if (!fs.existsSync(dirPath)) return;
@@ -1389,6 +1320,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Remove local-only endpoints (endpoints in collection but not in spec)
// Verify file content before deleting — the file may have been modified by the user
// between the drift scan and sync execution, making the pre-computed filePath stale.
if (localOnlyToRemove?.length > 0) {
for (const endpoint of localOnlyToRemove) {
if (endpoint.filePath) {
@@ -1398,7 +1331,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
continue;
}
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
try {
const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
const content = fs.readFileSync(fullPath, 'utf8');
const parsed = parseRequest(content, { format: fileFormat });
if (parsed?.request) {
const fileMethod = parsed.request.method?.toUpperCase();
const fileUrlPath = normalizeUrlPath(parsed.request.url);
if (fileMethod === endpoint.method && fileUrlPath === endpoint.path) {
fs.unlinkSync(fullPath);
}
}
} catch (err) {
console.error(`[OpenAPI Sync] Error verifying file before removal ${endpoint.filePath}:`, err);
}
}
}
}
}
if (addNewRequests && diff.added?.length > 0 && newCollection) {
for (const endpoint of diff.added) {
const normalizedPath = normalizeUrlPath(endpoint.path);
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
const newItem = result?.item;
if (newItem) {
// Check if endpoint already exists in collection (prevents overwriting user customizations)
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
} else {
// Truly new — create file in the appropriate folder
let targetFolder = collectionPath;
if (result.folderName && groupBy === 'tags') {
targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
}
const requestContent = await stringifyRequestViaWorker(newItem, { format });
const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
}
}
}
@@ -1436,7 +1411,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
// Reuse newCollection if available, otherwise fall back to stored spec
let driftCollection = newCollection;
if (!driftCollection) {
const applySpecEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
const applySpecEntry = getSpecEntryForUrl(collectionPath);
const storedSpecPath = applySpecEntry ? path.join(getSpecsDir(), applySpecEntry.filename) : null;
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
try {
@@ -1485,20 +1460,17 @@ const registerOpenAPISyncIpc = (mainWindow) => {
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
}
const openapiSync = brunoConfig.openapi || [];
const syncIdx = openapiSync.findIndex((e) => e.sourceUrl === sourceUrl);
if (syncIdx !== -1) {
if (brunoConfig.openapi?.[0]) {
const updated = {
...openapiSync[syncIdx],
...brunoConfig.openapi[0],
lastSyncDate: new Date().toISOString()
};
// Only update specHash when we have a valid newSpec, otherwise preserve existing hash
if (diff.newSpec) {
updated.specHash = generateSpecHash(diff.newSpec);
}
openapiSync[syncIdx] = updated;
brunoConfig.openapi[0] = updated;
}
brunoConfig.openapi = openapiSync;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
@@ -1510,7 +1482,7 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Update OpenAPI sync configuration (e.g., source URL)
ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, oldSourceUrl, config }) => {
ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, config }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
@@ -1533,37 +1505,18 @@ const registerOpenAPISyncIpc = (mainWindow) => {
throw new Error('Invalid URL: only http and https URLs are allowed');
}
// Convert absolute local file paths to collection-relative (git-shareable)
if (path.isAbsolute(sanitizedConfig.sourceUrl)) {
sanitizedConfig.sourceUrl = path.relative(collectionPath, sanitizedConfig.sourceUrl);
}
// Resolve to absolute for consistent internal handling (saveBrunoConfig converts back to relative)
sanitizedConfig.sourceUrl = resolveSourceUrl(collectionPath, sanitizedConfig.sourceUrl);
// If sourceUrl is changing, remove the old entry and its metadata
const openapi = brunoConfig.openapi || [];
if (oldSourceUrl && oldSourceUrl !== sanitizedConfig.sourceUrl) {
const filteredOpenapi = openapi.filter((e) => e.sourceUrl !== oldSourceUrl);
brunoConfig.openapi = filteredOpenapi;
// Clean up metadata entry for old sourceUrl (keep spec file for potential re-use)
const meta = loadSpecMetadata();
if (meta[collectionPath]) {
meta[collectionPath] = meta[collectionPath].filter((e) => e.sourceUrl !== oldSourceUrl);
if (meta[collectionPath].length === 0) delete meta[collectionPath];
saveSpecMetadata(meta);
}
}
// Apply defaults for new entries
const updatedOpenapi = brunoConfig.openapi || [];
const idx = updatedOpenapi.findIndex((e) => e.sourceUrl === sanitizedConfig.sourceUrl);
const isNewEntry = idx === -1;
if (isNewEntry) {
// Update or create the single openapi entry
const existingEntry = brunoConfig.openapi?.[0];
if (existingEntry) {
brunoConfig.openapi = [{ ...existingEntry, ...sanitizedConfig }];
} else {
if (!('autoCheck' in sanitizedConfig)) sanitizedConfig.autoCheck = true;
if (!('autoCheckInterval' in sanitizedConfig)) sanitizedConfig.autoCheckInterval = 5;
updatedOpenapi.push(sanitizedConfig);
} else {
updatedOpenapi[idx] = { ...updatedOpenapi[idx], ...sanitizedConfig };
brunoConfig.openapi = [sanitizedConfig];
}
brunoConfig.openapi = updatedOpenapi;
// Save updated config
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
@@ -1576,9 +1529,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Save OpenAPI spec file and update sync metadata (used by both connect and import flows)
ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent, sourceUrl }) => {
ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent }) => {
try {
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl });
await saveSpecAndUpdateMetadata({ collectionPath, specContent });
return { success: true };
} catch (error) {
console.error('Error saving OpenAPI spec file:', error);
@@ -1606,9 +1559,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Read stored OpenAPI spec file from AppData
ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath, sourceUrl }) => {
ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath }) => {
try {
const entry = getSpecEntryForUrl(collectionPath, sourceUrl);
const entry = getSpecEntryForUrl(collectionPath);
if (!entry) return { error: 'Spec file not found' };
const specPath = path.join(getSpecsDir(), entry.filename);
if (!fs.existsSync(specPath)) return { error: 'Spec file not found' };
@@ -1619,31 +1572,22 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
// Remove OpenAPI sync configuration (disconnect sync)
ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, sourceUrl, deleteSpecFile = false }) => {
ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, deleteSpecFile = false }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
// Remove matching openapi entry from config array
if (brunoConfig.openapi?.length) {
brunoConfig.openapi = brunoConfig.openapi.filter((e) => e.sourceUrl !== sourceUrl);
if (brunoConfig.openapi.length === 0) {
delete brunoConfig.openapi;
}
}
// Save updated config
// Remove openapi config
delete brunoConfig.openapi;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
// Remove spec file from AppData if user opted in
// Remove spec file and metadata for this collection
const meta = loadSpecMetadata();
const entries = meta[collectionPath] || [];
const entry = entries.find((e) => e.sourceUrl === sourceUrl);
const entry = (meta[collectionPath] || [])[0];
if (entry && deleteSpecFile) {
const specPath = path.join(getSpecsDir(), entry.filename);
if (fs.existsSync(specPath)) fs.unlinkSync(specPath);
}
meta[collectionPath] = entries.filter((e) => e.sourceUrl !== sourceUrl);
if (meta[collectionPath].length === 0) delete meta[collectionPath];
delete meta[collectionPath];
saveSpecMetadata(meta);
return { success: true };

View File

@@ -45,7 +45,9 @@ const defaultPreferences = {
layout: {
responsePaneOrientation: 'horizontal'
},
beta: {},
beta: {
'openapi-sync': false
},
onboarding: {
hasLaunchedBefore: false,
hasSeenWelcomeModal: true
@@ -58,52 +60,6 @@ const defaultPreferences = {
enabled: false,
interval: 1000
},
keyBindings: {
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
closeBruno: {
mac: 'command+bind+q',
windows: 'ctrl+bind+shift+bind+q',
name: 'Close Bruno'
},
switchToPreviousTab: {
mac: 'command+bind+2',
windows: 'ctrl+bind+2',
name: 'Switch to Previous Tab'
},
switchToNextTab: {
mac: 'command+bind+1',
windows: 'ctrl+bind+1',
name: 'Switch to Next Tab'
},
moveTabLeft: {
mac: 'command+bind+[',
windows: 'ctrl+bind+[',
name: 'Move Tab Left'
},
moveTabRight: {
mac: 'command+bind+]',
windows: 'ctrl+bind+]',
name: 'Move Tab Right'
},
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
},
display: {
zoomPercentage: 100
},
@@ -154,6 +110,7 @@ const preferencesSchema = Yup.object().shape({
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
}),
beta: Yup.object({
'openapi-sync': Yup.boolean()
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean(),

Some files were not shown because too many files have changed in this diff Show More