-
-
-
setEditorContent(val)}
- onSave={readOnly ? undefined : handleSave}
- mode="yaml"
- font={get(preferences, 'font.codeFont', 'default')}
- />
- {!readOnly && onSave && (
-
- )}
+
+
+ setEditorContent(val)}
+ onSave={readOnly ? undefined : handleSave}
+ mode="yaml"
+ font={get(preferences, 'font.codeFont', 'default')}
+ />
+ {!readOnly && onSave && (
+
+ )}
+
+
+
+
+
+
+ {!swaggerReady && (
+
+
+
+ Generating preview…
+
-
-
-
-
-
-
+ )}
);
diff --git a/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js
index f50f0e054..853582907 100644
--- a/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js
@@ -17,6 +17,35 @@ const StyledWrapper = styled.div`
.react-tooltip {
z-index: 10;
}
+
+ section.main.dragging {
+ cursor: col-resize;
+ user-select: none;
+ }
+
+ div.dragbar-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 10px;
+ min-width: 10px;
+ padding: 0;
+ cursor: col-resize;
+ background: transparent;
+ position: relative;
+ flex-shrink: 0;
+
+ div.dragbar-handle {
+ display: flex;
+ height: 100%;
+ width: 1px;
+ border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
+ }
+
+ &:hover div.dragbar-handle {
+ border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ApiSpecPanel/index.js b/packages/bruno-app/src/components/ApiSpecPanel/index.js
index 35f574000..57311bfde 100644
--- a/packages/bruno-app/src/components/ApiSpecPanel/index.js
+++ b/packages/bruno-app/src/components/ApiSpecPanel/index.js
@@ -1,11 +1,11 @@
-import React, { forwardRef, useRef } from 'react';
+import React, { forwardRef, useRef, useCallback } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
-import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
+import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import toast from 'react-hot-toast';
@@ -21,7 +21,16 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
- const { filename, pathname, raw, uid } = apiSpec || {};
+ const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
+
+ const handleLeftPaneWidthChange = useCallback(
+ (w) => {
+ if (!uid) return;
+ dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
+ },
+ [dispatch, uid]
+ );
+
if (!uid) {
return
API Spec not found!
;
}
@@ -79,6 +88,8 @@ const ApiSpecPanel = () => {
dispatch(saveApiSpecToFile({ uid, content }))}
+ leftPaneWidth={leftPaneWidth ?? null}
+ onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
);
diff --git a/packages/bruno-app/src/components/OpenAPISpecTab/index.js b/packages/bruno-app/src/components/OpenAPISpecTab/index.js
index 8a67c9514..f9323b121 100644
--- a/packages/bruno-app/src/components/OpenAPISpecTab/index.js
+++ b/packages/bruno-app/src/components/OpenAPISpecTab/index.js
@@ -1,8 +1,11 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import find from 'lodash/find';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
+import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
@@ -17,7 +20,17 @@ const prettyPrintSpec = (content) => {
}
};
-const OpenAPISpecTab = ({ collection }) => {
+const OpenAPISpecTab = ({ collection, tabUid }) => {
+ const dispatch = useDispatch();
+ const leftPaneWidth = useSelector((state) => {
+ const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
+ return tab?.apiSpecLeftPaneWidth ?? null;
+ });
+ const handleLeftPaneWidthChange = useCallback(
+ (w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
+ [dispatch, tabUid]
+ );
+
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -26,6 +39,16 @@ const OpenAPISpecTab = ({ collection }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
+ // Latest env context for loadSpec's remote-fetch fallback. Kept out of
+ // loadSpec's deps so toggling a variable doesn't refire the spec load.
+ const envContextRef = useRef({});
+ envContextRef.current = {
+ activeEnvironmentUid: collection?.activeEnvironmentUid,
+ environments: collection?.environments,
+ runtimeVariables: collection?.runtimeVariables,
+ globalEnvironmentVariables: collection?.globalEnvironmentVariables
+ };
+
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -42,12 +65,7 @@ const OpenAPISpecTab = ({ collection }) => {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
- environmentContext: {
- activeEnvironmentUid: collection.activeEnvironmentUid,
- environments: collection.environments,
- runtimeVariables: collection.runtimeVariables,
- globalEnvironmentVariables: collection.globalEnvironmentVariables
- }
+ environmentContext: envContextRef.current
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
@@ -64,7 +82,7 @@ const OpenAPISpecTab = ({ collection }) => {
} finally {
setIsLoading(false);
}
- }, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
+ }, [collection?.pathname, collection?.uid, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
@@ -97,7 +115,12 @@ const OpenAPISpecTab = ({ collection }) => {
Showing spec file from {sourceUrl}.
)}
-
+
);
};
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index d8eaa4515..138f20dfa 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -376,7 +376,7 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'openapi-spec') {
- return
;
+ return
;
}
if (!item || !item.uid) {
diff --git a/packages/bruno-app/src/hooks/useDragResize/index.js b/packages/bruno-app/src/hooks/useDragResize/index.js
new file mode 100644
index 000000000..842c631e3
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useDragResize/index.js
@@ -0,0 +1,108 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * Drag-to-resize behavior for a horizontal split pane.
+ *
+ * Owns the transient drag state (cursor tracking, draft width, dragging flag)
+ * and clamps to [minLeft, container.width - minRight]. The persisted width and
+ * its setter are owned by the caller — pass them in as `width` / `onWidthChange`
+ * and the hook treats it as a controlled value.
+ *
+ * Render pattern in the consumer:
+ * const effectiveWidth = dragging ? dragWidth : width;
+ */
+export function useDragResize({ containerRef, width, onWidthChange, minLeft, minRight }) {
+ // Mirror the live drag width in a ref so handleMouseUp can read the final
+ // value without taking dragWidth as a dep (would re-create the handler on
+ // every mousemove and re-run the listener-attach effect).
+ const dragWidthRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+ const [dragWidth, setDragWidth] = useState(null);
+
+ const clamp = useCallback(
+ (w) => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) return w;
+ return Math.max(minLeft, Math.min(w, rect.width - minRight));
+ },
+ [containerRef, minLeft, minRight]
+ );
+
+ const handleMouseMove = useCallback(
+ (e) => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ e.preventDefault();
+ const clamped = clamp(e.clientX - rect.left);
+ dragWidthRef.current = clamped;
+ setDragWidth(clamped);
+ },
+ [containerRef, clamp]
+ );
+
+ const handleMouseUp = useCallback(
+ (e) => {
+ e.preventDefault();
+ const finalWidth = dragWidthRef.current;
+ dragWidthRef.current = null;
+ setDragging(false);
+ setDragWidth(null);
+ if (finalWidth != null && onWidthChange) {
+ onWidthChange(finalWidth);
+ }
+ },
+ [onWidthChange]
+ );
+
+ const onMouseDown = useCallback(
+ (e) => {
+ e.preventDefault();
+ const rect = containerRef.current?.getBoundingClientRect();
+ const seed = width != null ? width : rect ? rect.width / 2 : null;
+ const seedClamped = seed != null ? clamp(seed) : null;
+ dragWidthRef.current = seedClamped;
+ setDragWidth(seedClamped);
+ setDragging(true);
+ },
+ [containerRef, width, clamp]
+ );
+
+ const onDoubleClick = useCallback(
+ (e) => {
+ e.preventDefault();
+ if (onWidthChange) onWidthChange(null);
+ },
+ [onWidthChange]
+ );
+
+ useEffect(() => {
+ if (!dragging) return;
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [dragging, handleMouseMove, handleMouseUp]);
+
+ // Re-clamp the persisted width when the container resizes (e.g. window or
+ // parent pane shrinks). Only dispatches if the clamped value differs.
+ // widthRef avoids tearing down the observer on every width change — the
+ // observer reads the latest width through the ref instead.
+ const widthRef = useRef(width);
+ widthRef.current = width;
+ const hasWidth = width != null;
+ useEffect(() => {
+ if (!hasWidth || !containerRef.current) return;
+ const ro = new ResizeObserver(() => {
+ const clamped = clamp(widthRef.current);
+ if (clamped !== widthRef.current && onWidthChange) {
+ onWidthChange(clamped);
+ }
+ });
+ ro.observe(containerRef.current);
+ return () => ro.disconnect();
+ }, [hasWidth, clamp, onWidthChange, containerRef]);
+
+ return { dragging, dragWidth, dragbarProps: { onMouseDown, onDoubleClick } };
+}
diff --git a/packages/bruno-app/src/hooks/useDragResize/index.spec.js b/packages/bruno-app/src/hooks/useDragResize/index.spec.js
new file mode 100644
index 000000000..dffdbebe4
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useDragResize/index.spec.js
@@ -0,0 +1,193 @@
+const { describe, it, expect, beforeEach, afterEach, jest } = require('@jest/globals');
+import { renderHook, act } from '@testing-library/react';
+import { useRef } from 'react';
+import { useDragResize } from './index';
+
+const CONTAINER_WIDTH = 1000;
+const MIN_LEFT = 200;
+const MIN_RIGHT = 300;
+
+const makeContainer = (width = CONTAINER_WIDTH) => {
+ const el = document.createElement('div');
+ el.getBoundingClientRect = jest.fn(() => ({
+ left: 0,
+ top: 0,
+ width,
+ height: 600,
+ right: width,
+ bottom: 600,
+ x: 0,
+ y: 0
+ }));
+ return el;
+};
+
+const renderDragResize = ({ width, onWidthChange = jest.fn(), container } = {}) => {
+ const containerEl = container ?? makeContainer();
+ const result = renderHook(
+ ({ width, onWidthChange }) => {
+ const containerRef = useRef(containerEl);
+ return useDragResize({
+ containerRef,
+ width,
+ onWidthChange,
+ minLeft: MIN_LEFT,
+ minRight: MIN_RIGHT
+ });
+ },
+ { initialProps: { width, onWidthChange } }
+ );
+ return { ...result, containerEl, onWidthChange };
+};
+
+const fireMouse = (type, clientX) => {
+ act(() => {
+ document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
+ });
+};
+
+describe('useDragResize', () => {
+ let observers;
+
+ beforeEach(() => {
+ observers = [];
+ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
+ const instance = {
+ callback,
+ observe: jest.fn(),
+ disconnect: jest.fn()
+ };
+ observers.push(instance);
+ return instance;
+ });
+ });
+
+ afterEach(() => {
+ delete global.ResizeObserver;
+ });
+
+ it('returns false dragging and null dragWidth on initial render', () => {
+ const { result } = renderDragResize({ width: 500 });
+ expect(result.current.dragging).toBe(false);
+ expect(result.current.dragWidth).toBe(null);
+ });
+
+ it('onMouseDown seeds dragWidth from the width prop and flips dragging on', () => {
+ const { result } = renderDragResize({ width: 500 });
+
+ act(() => {
+ result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
+ });
+
+ expect(result.current.dragging).toBe(true);
+ expect(result.current.dragWidth).toBe(500);
+ });
+
+ it('onMouseDown seeds dragWidth to half the container when width is null', () => {
+ const { result } = renderDragResize({ width: null });
+
+ act(() => {
+ result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
+ });
+
+ expect(result.current.dragWidth).toBe(CONTAINER_WIDTH / 2);
+ });
+
+ it('onMouseDown clamps an out-of-bounds width seed; immediate mouseup commits the clamped value', () => {
+ // Persisted width is past the right bound (max = 1000 - 300 = 700).
+ // Without clamping the seed, an immediate mouseup would persist 800.
+ const { result, onWidthChange } = renderDragResize({ width: 800 });
+
+ act(() => {
+ result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
+ });
+
+ expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT);
+
+ fireMouse('mouseup', 800);
+
+ expect(onWidthChange).toHaveBeenCalledTimes(1);
+ expect(onWidthChange).toHaveBeenCalledWith(CONTAINER_WIDTH - MIN_RIGHT);
+ });
+
+ it('mousemove during drag updates dragWidth clamped to [minLeft, containerWidth - minRight]', () => {
+ const { result } = renderDragResize({ width: 500 });
+
+ act(() => {
+ result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
+ });
+
+ // Within bounds
+ fireMouse('mousemove', 600);
+ expect(result.current.dragWidth).toBe(600);
+
+ // Below minLeft → clamps up
+ fireMouse('mousemove', 50);
+ expect(result.current.dragWidth).toBe(MIN_LEFT);
+
+ // Above containerWidth - minRight → clamps down
+ fireMouse('mousemove', 950);
+ expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT);
+ });
+
+ it('mouseup commits the final width via onWidthChange and clears drag state', () => {
+ const { result, onWidthChange } = renderDragResize({ width: 500 });
+
+ act(() => {
+ result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
+ });
+ fireMouse('mousemove', 650);
+ fireMouse('mouseup', 650);
+
+ expect(onWidthChange).toHaveBeenCalledTimes(1);
+ expect(onWidthChange).toHaveBeenCalledWith(650);
+ expect(result.current.dragging).toBe(false);
+ expect(result.current.dragWidth).toBe(null);
+ });
+
+ it('onDoubleClick calls onWidthChange(null) to reset', () => {
+ const { result, onWidthChange } = renderDragResize({ width: 500 });
+
+ act(() => {
+ result.current.dragbarProps.onDoubleClick({ preventDefault: jest.fn() });
+ });
+
+ expect(onWidthChange).toHaveBeenCalledWith(null);
+ });
+
+ it('mousemove without an active drag is a no-op', () => {
+ const { result } = renderDragResize({ width: 500 });
+
+ fireMouse('mousemove', 600);
+
+ expect(result.current.dragging).toBe(false);
+ expect(result.current.dragWidth).toBe(null);
+ });
+
+ it('ResizeObserver re-clamps the persisted width when the container shrinks', () => {
+ const containerEl = makeContainer(1000);
+ const onWidthChange = jest.fn();
+ renderDragResize({ width: 800, onWidthChange, container: containerEl });
+
+ // 800 fits within 1000 - 300 (minRight) = 700? No: 800 > 700, so already
+ // out of bounds — but the effect only re-clamps on observed resize, so
+ // shrink the container and trigger the observer manually.
+ containerEl.getBoundingClientRect = jest.fn(() => ({
+ left: 0,
+ top: 0,
+ width: 600,
+ height: 600,
+ right: 600,
+ bottom: 600,
+ x: 0,
+ y: 0
+ }));
+
+ act(() => {
+ observers[0].callback();
+ });
+
+ // 800 clamped to 600 - 300 = 300
+ expect(onWidthChange).toHaveBeenCalledWith(300);
+ });
+});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
index 6c951c8a2..5a8dcea39 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
@@ -59,6 +59,13 @@ export const apiSpecSlice = createSlice({
setActiveApiSpecUid: (state, action) => {
state.activeApiSpecUid = action.payload.uid;
},
+ updateApiSpecPanelLeftPaneWidth: (state, action) => {
+ const { uid, leftPaneWidth } = action.payload;
+ const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
+ if (apiSpec) {
+ apiSpec.leftPaneWidth = leftPaneWidth;
+ }
+ },
removeApiSpec: (state, action) => {
const { uid } = action.payload;
let apiSpecIndex = state.apiSpecs.findIndex((c) => c.uid == uid);
@@ -70,7 +77,7 @@ export const apiSpecSlice = createSlice({
}
});
-export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid } = apiSpecSlice.actions;
+export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid, updateApiSpecPanelLeftPaneWidth } = apiSpecSlice.actions;
export default apiSpecSlice.reducer;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index af50e3bc1..3b713120a 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -159,6 +159,13 @@ export const tabsSlice = createSlice({
tab.requestPaneHeight = action.payload.requestPaneHeight;
}
},
+ updateApiSpecTabLeftPaneWidth: (state, action) => {
+ const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
+
+ if (tab) {
+ tab.apiSpecLeftPaneWidth = action.payload.apiSpecLeftPaneWidth;
+ }
+ },
updateRequestPaneTab: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
@@ -416,6 +423,7 @@ export const {
switchTab,
updateRequestPaneTabWidth,
updateRequestPaneTabHeight,
+ updateApiSpecTabLeftPaneWidth,
updateRequestPaneTab,
updateResponsePaneTab,
updateResponseFormat,