Files
bruno/packages/bruno-app/src/components/OpenAPISpecTab/index.js
Abhishek S Lal 04732fa3d1 feat(api-spec): drag-to-resize split pane with persisted width (#7866)
* Add drag-resize split pane for API Spec viewer

Introduce a drag-to-resize split pane for the API Spec viewer and persist left pane width. Adds a new useDragResize hook to manage dragging state and clamping, plus UI: dragbar styles, a loading state for the Swagger preview (onComplete + loader), and memoization of the Swagger renderer. Wire up persisted widths via Redux: add updateApiSpecPanelLeftPaneWidth (apiSpec slice) and updateApiSpecTabLeftPaneWidth (tabs slice), and propagate leftPaneWidth / onLeftPaneWidthChange through ApiSpecPanel, OpenAPISpecTab, RequestTabPanel and SpecViewer. Misc: pass tab uid into OpenAPISpecTab and add .gstack/ to .gitignore.

* Refactor SpecViewer and OpenAPISpecTab for improved loading and state management

- Updated SpecViewer to enhance loading state handling for Swagger content, ensuring a smoother user experience by preventing flashes of unrendered content.
- Refactored OpenAPISpecTab to streamline environment context management, optimizing the loading process for OpenAPI specifications.
- Simplified the useDragResize hook by removing unnecessary references and improving the handling of drag events, ensuring better performance and responsiveness during resizing actions.

* Enhance useDragResize hook to clamp width seed and improve test coverage

- Updated the useDragResize hook to clamp the width seed value, ensuring it stays within defined bounds during drag events.
- Added a new test case to verify that an out-of-bounds width seed is correctly clamped and persisted on immediate mouseup, enhancing the robustness of the drag-resize functionality.

* Remove .gstack/ from .gitignore

Delete the .gstack/ ignore entry and normalize the packages/bruno-converters/dist entry in .gitignore (deduplicated). No code changes; just tidy up ignore rules.
2026-05-05 22:28:05 +05:30

129 lines
4.2 KiB
JavaScript

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.
*/
const prettyPrintSpec = (content) => {
if (!content) return content;
if (content.trimStart()[0] !== '{') return content;
try {
return fastJsonFormat(content);
} catch {
return content;
}
};
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);
const [isRemote, setIsRemote] = useState(false);
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);
setIsRemote(false);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
collectionPath: collection.pathname
});
if (result.error) {
// Local file not found — fall back to fetching from remote URL
if (sourceUrl) {
const fetchResult = await ipcRenderer.invoke('renderer:fetch-openapi-spec', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: envContextRef.current
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
setIsRemote(true);
return;
}
}
setError(result.error);
} else {
setSpecContent(prettyPrintSpec(result.content));
}
} catch (err) {
setError(err.message || 'Failed to read spec file');
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
loadSpec();
}
}, [loadSpec]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full gap-2 opacity-50">
<IconLoader2 size={20} className="animate-spin" />
<span>Loading spec...</span>
</div>
);
}
if (error || !specContent) {
return (
<div className="flex items-center justify-center h-full opacity-50">
<span>{error || 'No spec file found. Sync your collection first.'}</span>
</div>
);
}
return (
<StyledWrapper className="flex flex-col flex-grow relative">
{isRemote && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs opacity-60" style={{ borderBottom: '1px solid var(--color-border)' }}>
<IconCloud size={14} />
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer
content={specContent}
readOnly
leftPaneWidth={leftPaneWidth}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);
};
export default OpenAPISpecTab;