Feature: support import paths for gRPC (#5573)

* Enhance GrpcSettings component: update ui to improve user experience

Enhance GrpcSettings component: add import path management functionality

Refactor filesystem utility: remove duplicate isDirectory function and clean up code

Enhance GrpcQueryUrl component: add import path management and improve proto file selection functionality

Remove unused error message from GrpcQueryUrl component to streamline UI

Enhance GrpcSettings component: add editing functionality for proto files and import paths, improve UI for better user experience

Refactor GrpcSettings component: remove editing functionality for proto files and import paths, add replace import path feature, and update UI for improved feedback on file validity

Update GrpcQueryUrl component: change error message styling from red to yellow for improved visual feedback on invalid proto files and import paths

Refactor GrpcQueryUrl component: update styling for mode indicators and active tabs to use yellow color for improved visual consistency

Refactor ToggleSwitch component: add activeColor prop for customizable styling and update Checkbox background color logic to utilize activeColor

Update GrpcQueryUrl component: change dropdown and button styles to use yellow color for active states, enhancing visual consistency across the UI

Update GrpcSettings component: change error message styling from yellow to red for improved visibility and consistency in indicating invalid proto files and import paths

Refactor GrpcSettings component: remove hover background styles from table rows for a cleaner UI and maintain consistent button styling across actions

Refactor GrpcSettings component: remove Status column from the table and update error indication for invalid files with an alert icon for better visibility

Enhance Dropdown and GrpcQueryUrl components: add controlled visibility to Dropdown for improved interaction, and update loadGrpcMethodsFromProtoFile to accept collection for dynamic import paths, enhancing gRPC method loading functionality.

Refactor GrpcSettings component: streamline the display of proto files and import paths by consolidating empty state messages and enhancing error visibility with alert icons, while maintaining consistent table structure and button functionality.

Update GrpcQueryUrl component: simplify dependency array in useEffect and add conditional rendering for empty state messages regarding proto files and import paths, enhancing user feedback and clarity.

Refactor IconGrpc component: remove unused IconProto SVG definition to streamline the code and improve maintainability.

Refactor filesystem and network utility files: remove unnecessary blank lines to improve code readability and maintainability.

Update GrpcSettings and GrpcQueryUrl components: modify getBasename function to handle relative paths more effectively, and replace IconFile with IconFolder for improved visual consistency in the display of import paths.

Update Grpc components: enhance getBasename function to accept collection pathname for improved path resolution in GrpcSettings and GrpcQueryUrl, ensuring accurate display of proto file names.

Implement ProtobufSettings component: replace gRPC references with Protobuf, add functionality for managing proto files and import paths, and enhance UI with styled components for improved user experience.

Merge gRPC and Protobuf configurations for backward compatibility in CollectionSettings, ProtobufSettings, and GrpcQueryUrl components. Update state management and UI interactions to reflect the new structure, ensuring seamless transition from gRPC to Protobuf settings.

Add migration utility for gRPC to Protobuf configuration transition

Implement migration logic in collection-watcher to check and convert gRPC configurations to Protobuf format. Introduce a new utility for handling the migration process, ensuring backward compatibility and seamless updates to configuration files. This change enhances the application's ability to manage configuration transitions effectively.

Remove redundant migration logging and comments in collection-watcher.

Update loadGrpcMethodsFromProtoFile to use Protobuf configuration instead of gRPC. Adjust import path handling to reflect the new structure, ensuring compatibility with recent configuration transitions.

Enhance collection-watcher to send updated Protobuf configuration to the main process after migration. Remove redundant migration logic from the change function, streamlining the configuration handling process.

Add unit tests for gRPC to Protobuf migration utility

Introduce comprehensive tests for the migrateGrpcToProtobuf and needsMigration functions, covering various scenarios including config presence, merging, and handling of edge cases. This addition ensures the reliability of the migration process and validates the expected behavior of the utility functions.

Add initial tests for managing protofiles in Protobuf settings

Introduce a new test suite for managing protofiles, validating the visibility of protofiles and import paths in the Protobuf settings. The tests cover scenarios for loading methods from protofiles, handling invalid paths, and ensuring successful loading after providing necessary import paths. Additionally, a new collection configuration file is added to support the tests.

Reset gRPC methods state on loading errors in GrpcQueryUrl component. This ensures a clean state when encountering issues while loading methods from proto files, improving error handling and user feedback.

Enhance ProtobufSettings and GrpcQueryUrl components with data-test-ids for improved testing.

Refactor manage protofile tests to improve method loading verification. Update selectors for better specificity and ensure visibility of gRPC methods dropdown after selection.

Remove debug logging from getBasename function in path.js and refactor variable declaration in collection-watcher.js for improved clarity.

Refactor GrpcQueryUrl component to enhance dropdown item styling and improve method selection feedback. Update class names for better visual transitions and ensure consistent appearance across selected and hover states.

Refactor GrpcQueryUrl component by removing the GrpcurlModal implementation and its associated logic. This change streamlines the component and prepares for future enhancements.

Refactor GrpcQueryUrl component by introducing TabNavigation, ProtoFilesTab, and ImportPathsTab for improved organization and readability. This change enhances the user interface by streamlining tab management and separating concerns within the component.

Remove visibility check for loaded gRPC methods in manage protofile tests to streamline method selection process. Update selectors for improved specificity.

Refactor collection-watcher.js to remove gRPC migration logic and update configuration handling. Delete grpc-to-protobuf migration utility and associated tests to streamline codebase and eliminate redundancy.

Refactor GrpcQueryUrl component to rename gRPC-related functions and improve button click handling. Update dropdown item styling for consistency and enhance the visibility of proto files and import paths in the user interface. Add new test data for collection management and update paths in user data preferences.

Refactor path utility functions by removing getDirPath and updating exports in path.js. Adjust imports in Protobuf component to reflect these changes. Clean up filesystem.js by removing unused fs and fsPromises imports.

Refactor ProtobufSettings and GrpcQueryUrl components: improve code readability by standardizing arrow function syntax, enhancing UI feedback for proto files and import paths, and ensuring consistent styling across components.

Update manage protofile tests: change selector for collection path name to improve test specificity and ensure accurate visibility of protofiles in the Protobuf settings.

Refactor path utility functions and update component logic: modify getRelativePath and getBasename functions to accept parameters in a consistent order, enhancing path resolution across ProtobufSettings and GrpcQueryUrl components. Simplify filesystem utility functions by removing error handling for IPC calls, improving code clarity. Add comprehensive unit tests for path utilities to ensure reliability and correctness across different platforms.

fix: lint

feat: Add jsdocs to getAbsoluteFilePath utility function

refactor: Enhance GrpcQueryUrl and related components with styled wrappers for improved UI consistency

- Removed the "BETA" label from GrpcurlModal for a cleaner interface.
- Introduced StyledWrapper components for ImportPathsTab and ProtoFilesTab to encapsulate styling and improve readability.
- Updated TabNavigation to utilize StyledWrapper, enhancing the overall layout and design.
- Added new styles in the dark and light themes to support the updated UI elements, ensuring a cohesive look across components.

refactor: Enhance GrpcQueryUrl and related components with styled wrappers for improved UI consistency

- Removed the "BETA" label from GrpcurlModal for a cleaner interface.
- Introduced StyledWrapper components for ImportPathsTab and ProtoFilesTab to encapsulate styling and improve readability.
- Updated TabNavigation to utilize StyledWrapper, enhancing the overall layout and design.
- Added new styles in the dark and light themes to support the updated UI elements, ensuring a cohesive look across components.

refactor

feat: Enhance error handling and user feedback in GrpcQueryUrl and useProtoFileManagement

feat: Refactor GrpcQueryUrl component and introduce MethodDropdown and ProtoFileDropdown for improved user experience

- Removed unused imports and state variables to streamline the GrpcQueryUrl component.
- Introduced MethodDropdown for better organization of gRPC methods, enhancing selection and display.
- Added ProtoFileDropdown to manage proto file selection and import paths, improving user interaction.
- Updated UI elements for consistency and clarity, including dropdowns and method selection feedback.
- Enhanced error handling and user feedback mechanisms throughout the component.

refactor: rm comments

fix: linting

refactor: streamline proto file and import path management in useProtoFileManagement and useReflectionManagement hooks

refactor: use hook for protofile management within collection settings

fix: lint

fix: e2e tests

refactor: use getByTestId within playwright tests

refactor: enhance path utilities for cross-platform compatibility

* fix: lint

* test: add cleanup step to manage protofile tests for improved isolation
This commit is contained in:
sanish chirayath
2025-10-07 12:47:16 +05:30
committed by GitHub
parent 28907a203f
commit 1cc3a6432a
46 changed files with 3484 additions and 1291 deletions

View File

@@ -1,263 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import { existsSync, resolvePath } from '../../../utils/filesystem';
const GrpcSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { grpc: grpcConfig = {} }
} = collection;
const fileInputRef = useRef(null);
const [protoFileValidity, setProtoFileValidity] = useState({});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
protoFiles: grpcConfig.protoFiles || []
},
onSubmit: (newGrpcConfig) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.grpc = newGrpcConfig;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('gRPC settings updated');
}
});
// Get file path using the ipcRenderer
const getProtoFile = (event) => {
const files = event?.files;
if (files && files.length > 0) {
const newProtoFiles = [...formik.values.protoFiles];
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const protoFileObj = {
path: relativePath,
type: 'file'
};
// Check if this path already exists
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
if (!exists) {
newProtoFiles.push(protoFileObj);
}
}
}
formik.setFieldValue('protoFiles', newProtoFiles);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Handler for removing a proto file
const handleRemoveProtoFile = (index) => {
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles.splice(index, 1);
formik.setFieldValue('protoFiles', updatedProtoFiles);
};
// Handle the browse button click
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Check if a proto file path is valid
const isProtoFileValid = async (protoFile) => {
try {
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
return await existsSync(absolutePath);
} catch (error) {
return false;
}
};
// Validate all proto files and update state
useEffect(() => {
const validateProtoFiles = async () => {
const validityMap = {};
for (const file of formik.values.protoFiles) {
validityMap[file.path] = await isProtoFileValid(file);
}
setProtoFileValidity(validityMap);
};
validateProtoFiles();
}, [formik.values.protoFiles, collection.pathname]);
// Handle replacing an invalid proto file
const handleReplaceProtoFile = (index) => {
if (fileInputRef.current) {
fileInputRef.current.click();
// Store the index to replace after file selection
fileInputRef.current.dataset.replaceIndex = index;
}
};
// Handle file input change
const handleFileInputChange = (e) => {
const replaceIndex = e.target.dataset.replaceIndex;
if (replaceIndex !== undefined) {
// Handle replacement
const files = e.target.files;
if (files && files.length > 0) {
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles[replaceIndex] = {
path: relativePath,
type: 'file'
};
formik.setFieldValue('protoFiles', updatedProtoFiles);
}
}
delete e.target.dataset.replaceIndex;
} else {
getProtoFile(e.target);
}
};
return (
<StyledWrapper className="h-full w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3">
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
Add Proto Files
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
<div className="flex flex-col">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
<div className="flex flex-col gap-3">
{/* File selection options */}
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<button
type="button"
className="btn btn-sm btn-secondary flex items-center"
onClick={handleBrowseClick}
>
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
Browse for proto files
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-600 my-2"></div>
{/* List of added proto files */}
<div>
<div className="text-sm font-semibold mb-2 flex items-center">
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
Added Proto Files ({formik.values.protoFiles.length})
</div>
{formik.values.protoFiles.length === 0 ? (
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
) : (
<>
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
</div>
)}
<ul className="mt-4">
{formik.values.protoFiles.map((file, index) => {
const isValid = protoFileValidity[file.path];
return (
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
<div
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
title={file.path}
>
{getBasename(file.path)}
<span className="text-xs text-neutral-500 ml-2">
{getDirPath(file.path)}
</span>
</div>
</div>
<div className="flex w-full items-center justify-end">
{!isValid && (
<div className="flex items-center mr-2">
<IconAlertCircle
size={16}
className="text-red-500"
title="Proto file not found. Click to replace."
/>
<button
type="button"
className="text-xs text-red-500 ml-1 hover:underline"
onClick={() => handleReplaceProtoFile(index)}
>
Replace
</button>
</div>
)}
<button
type="button"
className="remove-certificate ml-2"
onClick={() => handleRemoveProtoFile(index)}
title="Remove file"
>
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default GrpcSettings;

View File

@@ -0,0 +1,336 @@
import React, { useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import {
IconTrash,
IconFile,
IconFileImport,
IconAlertCircle,
IconFolder
} from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
const ProtobufSettings = ({ collection }) => {
const {
protoFiles,
importPaths,
addProtoFileToCollection,
addImportPathToCollection,
toggleImportPath,
browseForProtoFile,
browseForImportDirectory,
removeProtoFileFromCollection,
removeImportPathFromCollection,
replaceImportPathInCollection,
replaceProtoFileInCollection
} = useProtoFileManagement(collection);
const fileInputRef = useRef(null);
// Get file path using the ipcRenderer
const getProtoFile = async (event) => {
const files = event?.files;
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
await addProtoFileToCollection(filePath);
}
}
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveProtoFile = async (index) => {
await removeProtoFileFromCollection(index);
};
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleReplaceProtoFile = async (index) => {
const result = await browseForProtoFile();
if (result.success) {
await replaceProtoFileInCollection(index, result.filePath);
}
};
const handleReplaceImportPath = async (index) => {
const result = await browseForImportDirectory();
if (result.success) {
await replaceImportPathInCollection(index, result.directoryPath);
}
};
const handleFileInputChange = (e) => {
getProtoFile(e.target);
};
const getImportPath = async () => {
const result = await browseForImportDirectory();
if (result.success) {
await addImportPathToCollection(result.directoryPath);
}
};
const handleRemoveImportPath = async (index) => {
await removeImportPathFromCollection(index);
};
const handleToggleImportPath = async (index) => {
await toggleImportPath(index);
};
const handleBrowseImportPathClick = () => {
getImportPath();
};
return (
<StyledWrapper className="h-full w-full">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
{/* Proto Files Section */}
<div className="mb-6" data-testid="protobuf-proto-files-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="protoFiles">
Proto Files (
{protoFiles.length}
)
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
</div>
</div>
<div>
{protoFiles.some((file) => !file.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-files-message">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found. Use the replace option to update their locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-proto-files-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
File
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{protoFiles.length === 0 ? (
<tr>
<td colSpan="3" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFile size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No proto files added</span>
</div>
</td>
</tr>
) : (
protoFiles.map((file, index) => {
const isValid = file.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFile size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, file.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{file.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceProtoFile(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace file"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveProtoFile(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove file"
data-testid="protobuf-remove-file-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseClick} data-testid="protobuf-add-file-button">
+ Add Proto File
</button>
</div>
</div>
{/* Import Paths Section */}
<div className="mb-6" data-testid="protobuf-import-paths-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="importPaths">
Import Paths (
{importPaths.length}
)
<span id="import-paths-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="import-paths-tooltip"
className="tooltip-mod font-normal"
html="Add directories that contain proto files to be imported. These paths help resolve import statements in your proto files."
/>
</label>
</div>
</div>
<div>
{importPaths.some((path) => !path.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-import-paths-message">
<IconAlertCircle size={14} className="mr-1" />
Some import paths cannot be found at their specified locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-import-paths-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Directory
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{importPaths.length === 0 ? (
<tr>
<td colSpan="4" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFolder size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No import paths added</span>
</div>
</td>
</tr>
) : (
importPaths.map((importPath, index) => {
const isValid = importPath.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<input
type="checkbox"
checked={importPath.enabled}
onChange={() => handleToggleImportPath(index)}
className="h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300 dark:border-gray-600 rounded"
title={importPath.enabled ? 'Disable this import path' : 'Enable this import path'}
data-testid="protobuf-import-path-checkbox"
/>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFolder size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, importPath.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{importPath.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceImportPath(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace directory"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveImportPath(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove import path"
data-testid="protobuf-remove-import-path-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseImportPathClick} data-testid="protobuf-add-import-path-button">
+ Add Import Path
</button>
</div>
</div>
</StyledWrapper>
);
};
export default ProtobufSettings;

View File

@@ -13,7 +13,7 @@ import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Grpc from './Grpc';
import Protobuf from './Protobuf';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
@@ -46,7 +46,7 @@ const CollectionSettings = ({ collection }) => {
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
@@ -123,8 +123,8 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'grpc': {
return <Grpc collection={collection} />;
case 'protobuf': {
return <Protobuf collection={collection} />;
}
}
};
@@ -172,9 +172,9 @@ const CollectionSettings = ({ collection }) => {
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
gRPC
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>

View File

@@ -2,7 +2,12 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: 'parent' }
: { ...props, trigger: 'click', interactive: true, appendTo: 'parent' };
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
@@ -11,10 +16,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }
animation={false}
arrow={false}
onCreate={onCreate}
interactive={true}
trigger="click"
appendTo="parent"
{...props}
{...tippyProps}
>
{icon}
</Tippy>

View File

@@ -90,4 +90,4 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);
);

View File

@@ -17,229 +17,218 @@ import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const { name, content } = message;
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
}
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(
methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
}
);
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(
updateRequestBody({
content: currentMessages,
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}));
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ?
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" /> :
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? "Send gRPC message" : "Connection not active"} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(collection.pathname, protoPath);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
});
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed
? <IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
: <IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? 'Send gRPC message' : 'Connection not active'} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "h-80"} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode='application/ld+json'
enableVariableHighlighting={true}
/>
</div>
)}
</div>
)
}
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -248,10 +237,10 @@ const GrpcBody = ({ item, collection, handleRun }) => {
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
@@ -259,7 +248,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
container.scrollTop = container.scrollHeight;
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages(prev => {
if (prev.includes(index)) {
@@ -269,26 +258,23 @@ const GrpcBody = ({ item, collection, handleRun }) => {
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
@@ -296,7 +282,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
@@ -308,21 +294,21 @@ const GrpcBody = ({ item, collection, handleRun }) => {
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "overflow-y-auto"} ${canClientSendMultipleMessages && "pb-16"}`}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
<SingleGrpcMessage
key={index}
message={message}
item={item}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
@@ -331,13 +317,13 @@ const GrpcBody = ({ item, collection, handleRun }) => {
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
@@ -351,4 +337,4 @@ const GrpcBody = ({ item, collection, handleRun }) => {
);
};
export default GrpcBody;
export default GrpcBody;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconCheck, IconCopy } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import Modal from 'components/Modal/index';
import CodeEditor from 'components/CodeEditor';
const GrpcurlModal = ({ isOpen, onClose, command }) => {
const { displayedTheme } = useTheme();
const [copied, setCopied] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
toast.success('Command copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast.error('Failed to copy command');
}
};
return (
<Modal
isOpen={isOpen}
handleCancel={onClose}
title={(
<div className="flex items-center gap-2">
<span>Generate gRPCurl Command</span>
</div>
)}
size="lg"
hideFooter={true}
>
<div>
<div className="flex w-full min-h-[400px]">
<div className="flex-grow relative">
<div className="absolute top-2 right-2 z-10">
<button
onClick={handleCopy}
className="btn btn-sm btn-secondary flex items-center gap-2"
>
{copied ? <IconCheck size={20} /> : <IconCopy size={20} />}
</button>
</div>
<CodeEditor
value={command}
theme={displayedTheme}
readOnly={true}
mode="shell"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
</div>
</div>
</div>
</Modal>
);
};
export default GrpcurlModal;

View File

@@ -0,0 +1,131 @@
import React, { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown/index';
import {
IconGrpcUnary,
IconGrpcClientStreaming,
IconGrpcServerStreaming,
IconGrpcBidiStreaming
} from 'components/Icons/Grpc';
const MethodDropdown = ({
grpcMethods,
selectedGrpcMethod,
onMethodSelect,
onMethodDropdownCreate
}) => {
const groupMethodsByService = (methods) => {
if (!methods || !methods.length) return {};
const groupedMethods = {};
methods.forEach((method) => {
const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path;
const parts = pathWithoutLeadingSlash.split('/');
const serviceName = parts[0] || 'Default';
const methodName = parts[1] || method.path;
const enhancedMethod = {
...method,
serviceName,
methodName
};
if (!groupedMethods[serviceName]) {
groupedMethods[serviceName] = [];
}
groupedMethods[serviceName].push(enhancedMethod);
});
return groupedMethods;
};
const getIconForMethodType = (type) => {
switch (type) {
case 'unary':
return <IconGrpcUnary size={20} strokeWidth={2} />;
case 'client-streaming':
return <IconGrpcClientStreaming size={20} strokeWidth={2} />;
case 'server-streaming':
return <IconGrpcServerStreaming size={20} strokeWidth={2} />;
case 'bidi-streaming':
return <IconGrpcBidiStreaming size={20} strokeWidth={2} />;
default:
return <IconGrpcUnary size={20} strokeWidth={2} />;
}
};
const MethodsDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center ml-2 cursor-pointer select-none">
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
<span className="text-xs">
{selectedGrpcMethod ? (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">
{selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
</span>
) : (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">Select Method </span>
)}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
const handleGrpcMethodSelect = (method) => {
const methodType = method.type;
onMethodSelect({ path: method.path, type: methodType });
};
if (!grpcMethods || grpcMethods.length === 0) {
return null;
}
return (
<div className="flex items-center h-full mr-2" data-testid="grpc-methods-dropdown">
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }}>
<div className="max-h-96 overflow-y-auto max-w-96 min-w-60" data-testid="grpc-methods-list">
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
<div key={serviceIndex} className="service-group mb-2">
<div className="service-header px-3 py-1 bg-neutral-100 dark:bg-neutral-800 text-sm font-medium truncate sticky top-0 z-10">
{serviceName || 'Default Service'}
</div>
<div className="service-methods">
{methods.map((method, methodIndex) => (
<div
key={`${serviceIndex}-${methodIndex}`}
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
selectedGrpcMethod && selectedGrpcMethod.path === method.path
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`}
onClick={() => handleGrpcMethodSelect(method)}
data-testid="grpc-method-item"
>
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
{getIconForMethodType(method.type)}
</div>
<div className="flex flex-col flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{method.methodName}
</div>
<div className="text-xs text-gray-500">
{method.type}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</Dropdown>
</div>
);
};
export default MethodDropdown;

View File

@@ -0,0 +1,217 @@
import React, { forwardRef, useState } from 'react';
import { IconFile, IconChevronDown } from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { updateRequestProtoPath } from 'providers/ReduxStore/slices/collections';
import { openCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown/index';
import ToggleSwitch from 'components/ToggleSwitch/index';
import { TabNavigation, ProtoFilesTab, ImportPathsTab } from '../Tabs';
import useProtoFileManagement from 'hooks/useProtoFileManagement/index';
const ProtoFileDropdown = ({
collection,
item,
isReflectionMode,
protoFilePath,
showProtoDropdown,
setShowProtoDropdown,
onProtoDropdownCreate,
onReflectionModeToggle,
onProtoFileLoad
}) => {
const { theme } = useTheme();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('protofiles'); // 'protofiles' or 'importpaths'
const protoFileManagement = useProtoFileManagement(collection, protoFilePath);
const invalidProtoFiles = protoFileManagement.protoFiles.filter((file) => !file.exists);
const invalidImportPaths = protoFileManagement.importPaths.filter((path) => !path.exists);
const handleSelectProtoFile = async (e) => {
e.stopPropagation();
const { success, filePath, error } = await protoFileManagement.browseForProtoFile();
if (!success) {
if (error) {
toast.error(`Failed to browse for proto file: ${error.message}`);
}
return;
}
const { success: addSuccess, relativePath, alreadyExists, error: addError } = await protoFileManagement.addProtoFileToCollection(filePath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add proto file: ${addError.message}`);
}
return;
}
if (alreadyExists) {
toast.error('Proto file already exists in collection settings');
} else {
toast.success('Added proto file to collection');
}
dispatch(updateRequestProtoPath({
protoPath: relativePath,
itemUid: item.uid,
collectionUid: collection.uid
}));
setShowProtoDropdown(false);
onProtoFileLoad(relativePath);
};
const handleSelectCollectionProtoFile = (protoFile) => {
if (!protoFile || !protoFile.exists) {
toast.error('Proto file not found');
return;
}
setShowProtoDropdown(false);
dispatch(updateRequestProtoPath({
protoPath: protoFile.path,
itemUid: item.uid,
collectionUid: collection.uid
}));
onProtoFileLoad(protoFile.path);
};
const handleBrowseImportPath = async (e) => {
e.stopPropagation();
const { success, directoryPath, error } = await protoFileManagement.browseForImportDirectory();
if (!success) {
if (error) {
toast.error(`Failed to browse for import directory: ${error.message}`);
}
return;
}
const { success: addSuccess, error: addError } = await protoFileManagement.addImportPathToCollection(directoryPath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add import path: ${addError.message}`);
}
return;
}
toast.success('Added import path to collection');
};
const handleToggleImportPath = async (index) => {
const { success, enabled, error } = await protoFileManagement.toggleImportPath(index);
if (!success) {
if (error) {
toast.error(`Failed to toggle import path: ${error.message}`);
}
return;
}
toast.success(`Import path ${enabled ? 'enabled' : 'disabled'}`);
};
const handleOpenCollectionProtobufSettings = (e) => {
e.stopPropagation();
dispatch(openCollectionSettings(collection.uid, 'protobuf'));
};
const ProtoFileDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center cursor-pointer select-none" onClick={() => setShowProtoDropdown((prev) => !prev)} data-testid="grpc-proto-file-dropdown-icon">
{isReflectionMode ? (<></>
) : (
<IconFile size={20} strokeWidth={1.5} className="mr-1 text-neutral-400" />
)}
<span className="text-xs dark:text-neutral-300 text-neutral-700 text-nowrap">
{isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(collection.pathname, protoFilePath) : 'Select Proto File')}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
return (
<div className="proto-file-dropdown">
<Dropdown
onCreate={onProtoDropdownCreate}
icon={<ProtoFileDropdownIcon />}
placement="bottom-end"
visible={showProtoDropdown}
onClickOutside={() => setShowProtoDropdown(false)}
data-testid="grpc-proto-file-dropdown"
>
<div className="max-h-fit overflow-y-auto w-[30rem]">
<div className="px-3 py-2 border-b border-neutral-200 dark:border-neutral-700" data-testid="grpc-mode-toggle">
<div className="flex items-center justify-between">
<span className="text-sm">Mode</span>
<div className="flex items-center gap-2">
<span className={`text-xs ${!isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: !isReflectionMode ? theme.colors.text.yellow : undefined }}>
Proto File
</span>
<ToggleSwitch
isOn={isReflectionMode}
handleToggle={onReflectionModeToggle}
size="2xs"
activeColor={theme.colors.text.yellow}
/>
<span className={`text-xs ${isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: isReflectionMode ? theme.colors.text.yellow : undefined }}>
Reflection
</span>
</div>
</div>
</div>
{!isReflectionMode && (
<TabNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
collectionProtoFiles={protoFileManagement.protoFiles}
collectionImportPaths={protoFileManagement.importPaths}
/>
)}
{!isReflectionMode && (
<>
{activeTab === 'protofiles' && (
<ProtoFilesTab
collectionProtoFiles={protoFileManagement.protoFiles}
invalidProtoFiles={invalidProtoFiles}
protoFilePath={protoFilePath}
collection={collection}
onSelectCollectionProtoFile={handleSelectCollectionProtoFile}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onSelectProtoFile={handleSelectProtoFile}
setShowProtoDropdown={setShowProtoDropdown}
/>
)}
{activeTab === 'importpaths' && (
<ImportPathsTab
collectionImportPaths={protoFileManagement.importPaths}
invalidImportPaths={invalidImportPaths}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onBrowseImportPath={handleBrowseImportPath}
onToggleImportPath={handleToggleImportPath}
/>
)}
</>
)}
{isReflectionMode && (
<div className="px-3 py-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
Using server reflection to discover gRPC methods.
</div>
</div>
)}
</div>
</Dropdown>
</div>
);
};
export default ProtoFileDropdown;

View File

@@ -0,0 +1,154 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.content-wrapper {
padding: 0.5rem 0.75rem;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.header-text {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.header.text};
}
.settings-button {
color: ${(props) => props.theme.grpc.importPaths.header.button.color};
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.header.button.hoverColor};
}
}
.error-wrapper {
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: ${(props) => props.theme.grpc.importPaths.error.bg};
border-radius: 0.25rem;
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.error.text};
}
.error-text {
display: flex;
align-items: center;
margin: 0;
}
.error-link {
color: ${(props) => props.theme.grpc.importPaths.error.link.color};
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
font-size: inherit;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.error.link.hoverColor};
}
}
.items-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 15rem;
overflow: auto;
max-width: 30rem;
}
.item-wrapper {
padding: 0.5rem 0.75rem;
opacity: ${(props) => props.theme.grpc.importPaths.item.invalid.opacity};
&.valid {
opacity: 1;
}
}
.item-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-left {
display: flex;
align-items: center;
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-right: 0.75rem;
}
.checkbox {
margin-right: 0.5rem;
cursor: pointer;
color: ${(props) => props.theme.grpc.importPaths.item.checkbox.color};
}
.item-text {
display: flex;
align-items: center;
font-size: 0.75rem;
white-space: nowrap;
}
.invalid-icon {
color: ${(props) => props.theme.grpc.importPaths.item.invalid.text};
font-size: 0.75rem;
display: flex;
align-items: center;
}
.empty-wrapper {
padding: 0.5rem 0.75rem;
}
.empty-text {
color: ${(props) => props.theme.grpc.importPaths.empty.text};
font-size: 0.875rem;
font-style: italic;
text-align: center;
padding: 0.5rem 0;
}
.button-wrapper {
padding: 0.5rem 0.75rem;
}
.browse-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${(props) => props.theme.grpc.importPaths.button.bg};
color: ${(props) => props.theme.grpc.importPaths.button.color};
border: 1px solid ${(props) => props.theme.grpc.importPaths.button.border};
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: ${(props) => props.theme.grpc.importPaths.button.hoverBorder};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { IconFolder, IconSettings, IconAlertCircle, IconFileImport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ImportPathsTab = ({
collectionImportPaths,
invalidImportPaths,
onOpenCollectionProtobufSettings,
onBrowseImportPath,
onToggleImportPath
}) => {
return (
<StyledWrapper>
{collectionImportPaths && collectionImportPaths.length > 0 && (
<div className="content-wrapper">
<div className="header-wrapper">
<div className="header-text">From Collection Settings</div>
<button
onClick={onOpenCollectionProtobufSettings}
className="settings-button"
>
<IconSettings size={16} strokeWidth={1.5} />
</button>
</div>
{invalidImportPaths.length > 0 && (
<div className="error-wrapper">
<p className="error-text">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Some import paths could not be found.
{' '}
<button
onClick={onOpenCollectionProtobufSettings}
className="error-link"
>
Manage import paths
</button>
</p>
</div>
)}
<div className="items-container">
{collectionImportPaths.map((importPath, index) => {
const isInvalid = !importPath.exists;
return (
<div
key={`collection-import-${index}`}
className={`item-wrapper ${!isInvalid ? 'valid' : ''}`}
>
<div className="item-content">
<div className="item-left">
<div className="checkbox-wrapper">
<input
type="checkbox"
checked={importPath.enabled}
disabled={isInvalid}
onChange={() => onToggleImportPath(index)}
className="checkbox"
title={importPath.enabled ? 'Import path enabled' : 'Import path disabled'}
/>
</div>
<IconFolder size={20} strokeWidth={1.5} style={{ marginRight: '0.5rem', color: 'inherit' }} />
<div className="item-text">
{importPath.path}
{isInvalid && (
<span className="invalid-icon">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ margin: '0 0.25rem' }} />
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(!collectionImportPaths || collectionImportPaths.length === 0) && (
<div className="empty-wrapper">
<div className="empty-text">
No import paths configured in collection settings
</div>
</div>
)}
<div className="button-wrapper">
<button
className="browse-button"
onClick={onBrowseImportPath}
>
<IconFileImport size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Browse for Import Path
</button>
</div>
</StyledWrapper>
);
};
export default ImportPathsTab;

View File

@@ -0,0 +1,172 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.content-wrapper {
padding: 0.5rem 0.75rem;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.header-text {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.header.text};
}
.settings-button {
color: ${(props) => props.theme.grpc.protoFiles.header.button.color};
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease;
&:hover {
color: ${(props) => props.theme.grpc.protoFiles.header.button.hoverColor};
}
}
.error-wrapper {
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: ${(props) => props.theme.grpc.protoFiles.error.bg};
border-radius: 0.25rem;
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.error.text};
}
.error-text {
display: flex;
align-items: center;
margin: 0;
}
.error-link {
color: ${(props) => props.theme.grpc.protoFiles.error.link.color};
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
font-size: inherit;
&:hover {
color: ${(props) => props.theme.grpc.protoFiles.error.link.hoverColor};
}
}
.items-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 15rem;
overflow-y: auto;
}
.item-wrapper {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-left: 2px solid transparent;
background-color: ${(props) => props.theme.grpc.protoFiles.item.bg};
transition: all 0.2s ease;
opacity: ${(props) => props.theme.grpc.protoFiles.item.invalid.opacity};
&.valid {
opacity: 1;
}
&.selected {
border-left-color: ${(props) => props.theme.grpc.protoFiles.item.selected.border};
background-color: ${(props) => props.theme.grpc.protoFiles.item.selected.bg};
}
&:hover {
background-color: ${(props) => props.theme.grpc.protoFiles.item.hoverBg};
&.selected {
background-color: ${(props) => props.theme.grpc.protoFiles.item.selected.bg};
}
}
}
.item-content {
display: flex;
align-items: center;
}
.item-icon {
margin-right: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.item.icon};
}
.item-details {
display: flex;
flex-direction: column;
}
.item-title {
font-size: 0.875rem;
display: flex;
align-items: center;
color: ${(props) => props.theme.grpc.protoFiles.item.text};
}
.item-path {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.protoFiles.item.secondaryText};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12.5rem;
}
.invalid-icon {
color: ${(props) => props.theme.grpc.protoFiles.item.invalid.text};
font-size: 0.75rem;
display: flex;
align-items: center;
margin-left: 0.5rem;
}
.empty-wrapper {
padding: 0.5rem 0.75rem;
}
.empty-text {
color: ${(props) => props.theme.grpc.protoFiles.empty.text};
font-size: 0.875rem;
font-style: italic;
text-align: center;
padding: 0.5rem 0;
}
.button-wrapper {
padding: 0.5rem 0.75rem;
}
.browse-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${(props) => props.theme.grpc.protoFiles.button.bg};
color: ${(props) => props.theme.grpc.protoFiles.button.color};
border: 1px solid ${(props) => props.theme.grpc.protoFiles.button.border};
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: ${(props) => props.theme.grpc.protoFiles.button.hoverBorder};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { IconFile, IconSettings, IconAlertCircle } from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import StyledWrapper from './StyledWrapper';
const ProtoFilesTab = ({
collectionProtoFiles,
invalidProtoFiles,
protoFilePath,
collection,
onSelectCollectionProtoFile,
onOpenCollectionProtobufSettings,
onSelectProtoFile
}) => {
return (
<StyledWrapper>
{collectionProtoFiles && collectionProtoFiles.length > 0 && (
<div className="content-wrapper">
<div className="header-wrapper">
<div className="header-text">From Collection Settings</div>
<button
onClick={onOpenCollectionProtobufSettings}
className="settings-button"
>
<IconSettings size={16} strokeWidth={1.5} />
</button>
</div>
{invalidProtoFiles.length > 0 && (
<div className="error-wrapper">
<p className="error-text">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Some proto files could not be found.
{' '}
<button
onClick={onOpenCollectionProtobufSettings}
className="error-link"
>
Manage proto files
</button>
</p>
</div>
)}
<div className="items-container">
{collectionProtoFiles.map((protoFile, index) => {
const isSelected = protoFilePath === protoFile.path;
const isInvalid = !protoFile.exists;
return (
<div
key={`collection-proto-${index}`}
className={`item-wrapper ${!isInvalid ? 'valid' : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => {
if (!isInvalid) {
onSelectCollectionProtoFile(protoFile);
}
}}
>
<div className="item-content">
<div className="item-icon">
<IconFile size={20} strokeWidth={1.5} />
</div>
<div className="item-details">
<div className="item-title">
{getBasename(collection.pathname, protoFile.path)}
{isInvalid && (
<span className="invalid-icon">
<IconAlertCircle size={14} strokeWidth={1.5} />
</span>
)}
</div>
<div className="item-path">
{protoFile.path}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(!collectionProtoFiles || collectionProtoFiles.length === 0) && (
<div className="empty-wrapper">
<div className="empty-text">
No proto files configured in collection settings
</div>
</div>
)}
<div className="button-wrapper">
<button
className="browse-button"
onClick={onSelectProtoFile}
>
<IconFile size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Browse for Proto File
</button>
</div>
</StyledWrapper>
);
};
export default ProtoFilesTab;

View File

@@ -0,0 +1,23 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.tab-container {
background-color: ${(props) => props.theme.grpc.tabNav.container.bg};
}
.tab-button {
background-color: ${(props) => props.theme.grpc.tabNav.button.inactive.bg};
color: ${(props) => props.theme.grpc.tabNav.button.inactive.color};
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
background-color: ${(props) => props.theme.grpc.tabNav.button.inactive.hover};
}
&.active {
background-color: ${(props) => props.theme.grpc.tabNav.button.active.bg};
color: ${(props) => props.theme.grpc.tabNav.button.active.color};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const TabNavigation = ({ activeTab, onTabChange, collectionProtoFiles, collectionImportPaths }) => {
return (
<StyledWrapper className="px-3 py-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="tab-container flex space-x-1 rounded-lg p-1">
<button
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors tab-button ${activeTab === 'protofiles' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onTabChange('protofiles');
}}
>
Proto Files (
{collectionProtoFiles?.length || 0}
)
</button>
<button
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors tab-button ${activeTab === 'importpaths' ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onTabChange('importpaths');
}}
>
Import Paths (
{collectionImportPaths?.length || 0}
)
</button>
</div>
</StyledWrapper>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,3 @@
export { default as TabNavigation } from './TabNavigation';
export { default as ProtoFilesTab } from './ProtoFilesTab';
export { default as ImportPathsTab } from './ImportPathsTab';

View File

@@ -26,7 +26,7 @@ export const Checkbox = styled.input`
height: 0;
&:checked + label div {
background-color: ${(props) => props.theme.textLink};
background-color: ${(props) => props.activeColor || props.theme.textLink};
}
&:checked + label div:before {

View File

@@ -1,9 +1,9 @@
import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', ...props }) => {
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => {
return (
<Switch size={size} {...props} onClick={handleToggle}>
<Checkbox checked={isOn} id="toggle-switch" type="checkbox" size={size} onChange={() => {}} />
<Checkbox checked={isOn} id="toggle-switch" type="checkbox" size={size} activeColor={activeColor} onChange={() => {}} />
<Label htmlFor="toggle-switch">
<Inner size={size} />
<SwitchButton size={size} />

View File

@@ -0,0 +1,297 @@
import { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { browseFiles, updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import { getRelativePath, getAbsoluteFilePath } from 'utils/common/path';
import { browseDirectory } from 'utils/filesystem';
import { loadGrpcMethodsFromProtoFile } from 'utils/network/index';
import useLocalStorage from 'hooks/useLocalStorage/index';
import { cloneDeep } from 'lodash';
/**
* Custom hook for managing protofile data and collection configuration
* @param {Object} collection - The collection object
* @param {string} currentProtoPath - Currently selected proto file path
*/
export default function useProtoFileManagement(collection) {
const dispatch = useDispatch();
const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const [isLoadingMethods, setIsLoadingMethods] = useState(false);
const collectionProtoFiles = useMemo(() => collection?.brunoConfig?.protobuf?.protoFiles || [], [collection?.brunoConfig?.protobuf?.protoFiles]);
const collectionImportPaths = useMemo(() => collection?.brunoConfig?.protobuf?.importPaths || [], [collection?.brunoConfig?.protobuf?.importPaths]);
const protoFilesWithExistence = useMemo(() =>
collectionProtoFiles.map((protoFile) => ({
path: protoFile.path,
exists: protoFile.exists || false
})), [collectionProtoFiles]);
const importPathsWithExistence = useMemo(() =>
collectionImportPaths.map((importPath) => ({
path: importPath.path,
exists: importPath.exists || false,
enabled: importPath.enabled || false
})), [collectionImportPaths]);
const loadMethodsFromProtoFile = async (filePath, isManualRefresh = false) => {
if (!filePath) {
return { methods: [], error: new Error('No proto file selected') };
}
const absolutePath = getAbsoluteFilePath(collection.pathname, filePath);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods && !isLoadingMethods && !isManualRefresh) {
return { methods: cachedMethods, error: null };
}
setIsLoadingMethods(true);
try {
const { methods, error } = await loadGrpcMethodsFromProtoFile(absolutePath, collection);
if (error) {
console.error('Error loading gRPC methods:', error);
return { methods: [], error };
}
setProtofileCache((prevCache) => ({
...prevCache,
[absolutePath]: methods
}));
return { methods, error: null };
} catch (err) {
console.error('Error loading gRPC methods:', err);
return { methods: [], error: err };
} finally {
setIsLoadingMethods(false);
}
};
const addProtoFileToCollection = async (filePath) => {
const relativePath = getRelativePath(collection.pathname, filePath, true);
const exists = collectionProtoFiles.some((pf) => pf.path === relativePath);
if (exists) {
return { success: true, relativePath, alreadyExists: true };
}
try {
const protoFileObj = {
path: relativePath,
type: 'file'
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
if (!brunoConfig.protobuf.protoFiles) {
brunoConfig.protobuf.protoFiles = [];
}
brunoConfig.protobuf.protoFiles = [...collectionProtoFiles, protoFileObj];
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true, relativePath };
} catch (error) {
console.error('Error adding proto file to collection:', error);
return { success: false, error };
}
};
const addImportPathToCollection = async (directoryPath) => {
const relativePath = getRelativePath(collection.pathname, directoryPath, true);
const importPathObj = {
path: relativePath,
enabled: true
};
const exists = collectionImportPaths.some((ip) => ip.path === importPathObj.path);
if (exists) {
return { success: false, error: new Error('Import path already exists') };
}
try {
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
if (!brunoConfig.protobuf.importPaths) {
brunoConfig.protobuf.importPaths = [];
}
brunoConfig.protobuf.importPaths = [...collectionImportPaths, importPathObj];
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true, relativePath };
} catch (error) {
console.error('Error adding import path:', error);
return { success: false, error };
}
};
const toggleImportPath = async (index) => {
try {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths[index] = {
...updatedImportPaths[index],
enabled: !updatedImportPaths[index].enabled
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.importPaths = updatedImportPaths;
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return {
success: true,
enabled: updatedImportPaths[index].enabled
};
} catch (error) {
console.error('Error toggling import path:', error);
return { success: false, error };
}
};
const browseForProtoFile = async () => {
const filters = [{ name: 'Proto Files', extensions: ['proto'] }];
try {
const filePaths = await dispatch(browseFiles(filters, ['']));
if (filePaths && filePaths.length > 0) {
return { success: true, filePath: filePaths[0] };
}
return { success: false, error: new Error('No file selected') };
} catch (error) {
console.error('Error browsing for proto file:', error);
return { success: false, error };
}
};
const browseForImportDirectory = async () => {
try {
const selectedPath = await browseDirectory(collection.pathname);
if (selectedPath) {
return { success: true, directoryPath: selectedPath };
}
return { success: false, error: new Error('No directory selected') };
} catch (error) {
console.error('Error browsing for import directory:', error);
return { success: false, error };
}
};
const removeProtoFileFromCollection = async (index) => {
try {
const updatedProtoFiles = [...collectionProtoFiles];
updatedProtoFiles.splice(index, 1);
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.protoFiles = updatedProtoFiles;
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true };
} catch (error) {
console.error('Error removing proto file:', error);
return { success: false, error };
}
};
const removeImportPathFromCollection = async (index) => {
try {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths.splice(index, 1);
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.importPaths = updatedImportPaths;
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true };
} catch (error) {
console.error('Error removing import path:', error);
return { success: false, error };
}
};
const replaceImportPathInCollection = async (index, newDirectoryPath) => {
try {
const relativePath = getRelativePath(collection.pathname, newDirectoryPath, true);
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths[index] = {
...updatedImportPaths[index],
path: relativePath
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.importPaths = updatedImportPaths;
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true };
} catch (error) {
console.error('Error replacing import path:', error);
return { success: false, error };
}
};
const replaceProtoFileInCollection = async (index, newFilePath) => {
try {
const relativePath = getRelativePath(collection.pathname, newFilePath, true);
const updatedProtoFiles = [...collectionProtoFiles];
updatedProtoFiles[index] = {
...updatedProtoFiles[index],
path: relativePath,
type: 'file'
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.protoFiles = updatedProtoFiles;
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
return { success: true };
} catch (error) {
console.error('Error replacing proto file:', error);
return { success: false, error };
}
};
return {
protoFiles: protoFilesWithExistence,
importPaths: importPathsWithExistence,
isLoadingMethods,
loadMethodsFromProtoFile,
addProtoFileToCollection,
addImportPathToCollection,
toggleImportPath,
browseForProtoFile,
browseForImportDirectory,
removeProtoFileFromCollection,
removeImportPathFromCollection,
replaceImportPathInCollection,
replaceProtoFileInCollection
};
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { loadGrpcMethodsFromReflection } from 'providers/ReduxStore/slices/collections/actions';
import useLocalStorage from 'hooks/useLocalStorage/index';
/**
* Custom hook for managing reflection data and server discovery
* @param {Object} item - The request item
* @param {string} collectionUid - Collection UID
*/
export default function useReflectionManagement(item, collectionUid) {
const dispatch = useDispatch();
const [reflectionCache, setReflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [isLoadingMethods, setIsLoadingMethods] = useState(false);
/**
* Load gRPC methods from server reflection
* @param {string} url - The gRPC server URL
* @param {boolean} isManualRefresh - Whether this is a manual refresh
* @returns {Promise<{methods: Array, error: Error|null}>}
*/
const loadMethodsFromReflection = async (url, isManualRefresh = false) => {
if (!url) {
return { methods: [], error: new Error('No URL provided') };
}
const cachedMethods = reflectionCache[url];
if (!isManualRefresh && cachedMethods && !isLoadingMethods) {
return { methods: cachedMethods, error: null };
}
setIsLoadingMethods(true);
try {
const { methods, error } = await dispatch(loadGrpcMethodsFromReflection(item, collectionUid, url));
if (error) {
console.error('Error loading gRPC methods:', error);
return { methods: [], error };
}
setReflectionCache((prevCache) => ({
...prevCache,
[url]: methods
}));
return { methods, error: null };
} catch (error) {
console.error('Error loading gRPC methods:', error);
return { methods: [], error };
} finally {
setIsLoadingMethods(false);
}
};
/**
* Check if methods are cached for a URL
* @param {string} url - The gRPC server URL
* @returns {boolean}
*/
const hasCachedMethods = (url) => {
return !!(reflectionCache[url] && reflectionCache[url].length > 0);
};
/**
* Get cached methods for a URL
* @param {string} url - The gRPC server URL
* @returns {Array}
*/
const getCachedMethods = (url) => {
return reflectionCache[url] || [];
};
/**
* Clear cache for a specific URL
* @param {string} url - The gRPC server URL
*/
const clearCacheForUrl = (url) => {
setReflectionCache((prevCache) => {
const newCache = { ...prevCache };
delete newCache[url];
return newCache;
});
};
/**
* Clear all reflection cache
*/
const clearAllCache = () => {
setReflectionCache({});
};
return {
isLoadingMethods,
reflectionCache,
loadMethodsFromReflection,
hasCachedMethods,
getCachedMethods,
clearCacheForUrl,
clearAllCache
};
}

View File

@@ -338,6 +338,104 @@ const darkTheme = {
scrollbarTrack: '#2d2d30',
scrollbarThumb: '#5a5a5a',
scrollbarThumbHover: '#6a6a6a'
},
grpc: {
tabNav: {
container: {
bg: '#262626'
},
button: {
active: {
bg: '#404040',
color: '#ffffff'
},
inactive: {
bg: 'transparent',
color: '#a3a3a3'
}
}
},
importPaths: {
header: {
text: '#9d9d9d',
button: {
color: '#9d9d9d',
hoverColor: '#d4d4d4'
}
},
error: {
bg: 'transparent',
text: '#f06f57',
link: {
color: '#f06f57',
hoverColor: '#ff8a7a'
}
},
item: {
bg: 'transparent',
hoverBg: 'rgba(255, 255, 255, 0.05)',
text: '#d4d4d4',
icon: '#9d9d9d',
checkbox: {
color: '#d4d4d4'
},
invalid: {
opacity: 0.6,
text: '#f06f57'
}
},
empty: {
text: '#9d9d9d'
},
button: {
bg: '#185387',
color: '#d4d4d4',
border: '#185387',
hoverBorder: '#696969'
}
},
protoFiles: {
header: {
text: '#9d9d9d',
button: {
color: '#9d9d9d',
hoverColor: '#d4d4d4'
}
},
error: {
bg: 'transparent',
text: '#f06f57',
link: {
color: '#f06f57',
hoverColor: '#ff8a7a'
}
},
item: {
bg: 'transparent',
hoverBg: 'rgba(255, 255, 255, 0.05)',
selected: {
bg: 'rgba(245, 158, 11, 0.2)',
border: '#f59e0b'
},
text: '#d4d4d4',
secondaryText: '#9d9d9d',
icon: '#9d9d9d',
invalid: {
opacity: 0.6,
text: '#f06f57'
}
},
empty: {
text: '#9d9d9d'
},
button: {
bg: '#185387',
color: '#d4d4d4',
border: '#185387',
hoverBorder: '#696969'
}
}
}
};

View File

@@ -339,6 +339,110 @@ const lightTheme = {
scrollbarTrack: '#f8f9fa',
scrollbarThumb: '#ced4da',
scrollbarThumbHover: '#adb5bd'
},
grpc: {
tabNav: {
container: {
bg: '#f5f5f5'
},
button: {
active: {
bg: '#ffffff',
color: '#000000'
},
inactive: {
bg: 'transparent',
color: '#525252'
}
}
},
importPaths: {
container: {
bg: '#ffffff'
},
header: {
text: '#838383',
button: {
color: '#838383',
hoverColor: '#343434'
}
},
error: {
bg: 'transparent',
text: '#B91C1C',
link: {
color: '#B91C1C',
hoverColor: '#dc2626'
}
},
item: {
bg: 'transparent',
hoverBg: 'rgba(0, 0, 0, 0.05)',
text: '#343434',
icon: '#838383',
checkbox: {
color: '#343434'
},
invalid: {
opacity: 0.6,
text: '#B91C1C'
}
},
empty: {
text: '#838383'
},
button: {
bg: '#e2e6ea',
color: '#212529',
border: '#dae0e5',
hoverBorder: '#696969'
}
},
protoFiles: {
container: {
bg: '#ffffff'
},
header: {
text: '#838383',
button: {
color: '#838383',
hoverColor: '#343434'
}
},
error: {
bg: 'transparent',
text: '#B91C1C',
link: {
color: '#B91C1C',
hoverColor: '#dc2626'
}
},
item: {
bg: 'transparent',
hoverBg: 'rgba(0, 0, 0, 0.05)',
selected: {
bg: 'rgba(217, 119, 6, 0.2)',
border: '#d97706'
},
text: '#343434',
secondaryText: '#838383',
icon: '#838383',
invalid: {
opacity: 0.6,
text: '#B91C1C'
}
},
empty: {
text: '#838383'
},
button: {
bg: '#e2e6ea',
color: '#212529',
border: '#dae0e5',
hoverBorder: '#696969'
}
}
}
};

View File

@@ -574,6 +574,20 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
delete collectionToSave.brunoConfig.proxy.auth.password;
}
if (collectionToSave?.brunoConfig?.protobuf?.importPaths) {
collectionToSave.brunoConfig.protobuf.importPaths = collectionToSave.brunoConfig.protobuf.importPaths.map((importPath) => {
delete importPath.exists;
return importPath;
});
}
if (collectionToSave?.brunoConfig?.protobuf?.protoFiles) {
collectionToSave.brunoConfig.protobuf.protoFiles = collectionToSave.brunoConfig.protobuf.protoFiles.map((protoFile) => {
delete protoFile.exists;
return protoFile;
});
}
copyItems(collection.items, collectionToSave.items);
return collectionToSave;
};

View File

@@ -7,34 +7,161 @@ const isWindowsOS = () => {
return osFamily.includes('windows');
};
/**
* Cross-Platform Path Standardization for Bruno Configuration Files
*
* Bruno stores relative paths in configuration files (bruno.json) that are committed to version control.
* This creates cross-platform compatibility challenges when Windows and Unix users collaborate on the same project.
*
* PROBLEM:
* - Windows users naturally create paths with backslashes (e.g., "certs\\client.pem")
* - Unix systems don't recognize backslashes as path separators
* - When a Windows user commits a bruno.json with Windows-style paths, Unix users cannot resolve these paths
* - This forces manual path conversion before git commits, which is error-prone and inconvenient
*
* SOLUTION:
* - Standardize all stored paths to POSIX format (forward slashes) across all platforms
* - Windows natively supports forward slashes as valid path separators
* - Use the posixify parameter to ensure consistent path storage
* - This enables seamless collaboration between Windows and Unix developers
*
* IMPLEMENTATION:
* - Always enable posixify by default when storing paths in configuration files
* - Client certificates, protobuf files, and other relative paths should use POSIX format
* - Both platforms can then resolve the same paths accurately without manual intervention
*
* BENEFITS:
* - No manual path conversion required before git commits
* - Consistent behavior across all platforms
* - Improved developer experience for cross-platform teams
* - Reduced git conflicts and merge issues related to path differences
*/
/** @param {string} str */
const posixify = (str) => {
return str.replace(/\\/g, '/');
};
const brunoPath = isWindowsOS() ? path.win32 : path.posix;
const getRelativePath = (absolutePath, collectionPath) => {
/**
* Get a relative path from one location to another.
*
* This function attempts to compute the relative path between two given paths
*
* @param {string} fromPath - The starting path.
* @param {string} toPath - The target path.
* @param {boolean} [shouldPosixify=true] - Whether to convert backslashes to forward slashes for cross-platform compatibility.
* @returns {string} The relative path from `fromPath` to `toPath`, `"."` if both are the same,
* or `toPath` if resolution fails.
*
* @example
* Assuming current dir: /users/john/projects
* getRelativePath('/users/john/projects', '/users/john/projects/app');
* → "app"
*
* @example
* getRelativePath('/users/john/projects', '/users/john/projects');
* → "."
*
* @example
* getRelativePath('/users/john/projects', '/users/john/docs/readme.md');
* → "../docs/readme.md"
*
* @example
* On Windows with posixify enabled
* getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Docs\\readme.md', true);
* → "../Docs/readme.md"
*/
const getRelativePath = (fromPath, toPath, shouldPosixify = true) => {
try {
const relativePath = brunoPath.relative(collectionPath, absolutePath);
return relativePath || absolutePath;
const relativePath = brunoPath.relative(fromPath, toPath);
if (relativePath === '') {
return '.';
}
const result = relativePath || toPath;
return shouldPosixify ? posixify(result) : result;
} catch (error) {
return absolutePath;
return shouldPosixify ? posixify(toPath) : toPath;
}
};
const getBasename = (filePath) => {
if (!filePath) {
/**
* Get the basename (filename) of a file from a relative path.
*
* This function resolves a relative path against a base path and returns
* just the filename portion. It handles cross-platform path separators
* and returns an empty string for invalid inputs.
*
* @param {string} basePath - The base path to resolve against (e.g., "/users/john/projects")
* @param {string} relativePath - The relative path to resolve (e.g., "../docs/file.txt")
* @returns {string} The basename of the resolved path, or empty string if relativePath is falsy
*
* @example
* getBasename("/users/john/projects", "../docs/readme.md");
* → "readme.md"
*
* @example
* getBasename("/users/john/projects", "subfolder/config.json");
* → "config.json"
*
* @example
* getBasename("/users/john/projects", "..");
* → "john"
*
* @example
* getBasename("/users/john/projects", ".");
* → "projects"
*/
const getBasename = (basePath, relativePath) => {
if (!relativePath) {
return '';
}
const parts = filePath.split(path.sep);
return parts[parts.length - 1];
const resolvedPath = brunoPath.resolve(basePath, relativePath);
const basename = brunoPath.basename(resolvedPath);
return basename;
};
const getDirPath = (filePath) => {
const parts = filePath.split(path.sep);
parts.pop();
return parts.join(path.sep);
};
const getAbsoluteFilePath = (filePath, collectionPath) => {
return brunoPath.resolve(collectionPath, filePath);
/**
* Resolve a relative file path against a base path to get an absolute file path.
*
* This function resolves a relative path against a base path using the appropriate
* path resolution method for the current platform (Windows or Unix). It handles
* cross-platform path separators and returns a normalized absolute path.
*
* @param {string} basePath - The base path to resolve against (e.g., "/users/john/collections" or "C:\\Users\\John\\Collections")
* @param {string} relativePath - The relative path to resolve (e.g., "config/settings.json" or "config\\settings.json")
* @param {boolean} [shouldPosixify=false] - Whether to convert backslashes to forward slashes for cross-platform compatibility.
* @returns {string} The resolved absolute file path
*
* @example
* Basic relative path resolution
* getAbsoluteFilePath('/users/john/collections', 'config/settings.json');
* → "/users/john/collections/config/settings.json"
*
* @example
* Handle parent directory references
* getAbsoluteFilePath('/users/john/collections/api', '../shared/config.json');
* → "/users/john/collections/shared/config.json"
*
* @example
* Handle current directory reference
* getAbsoluteFilePath('/users/john/collections', './local-file.json');
* → "/users/john/collections/local-file.json"
*
* @example
* On Windows with posixify enabled
* getAbsoluteFilePath('C:\\Users\\John\\Collections', 'config\\settings.json', true);
* → "C:/Users/John/Collections/config/settings.json"
*/
const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => {
const result = brunoPath.resolve(basePath, relativePath);
return shouldPosixify ? posixify(result) : result;
};
export default brunoPath;
export { getRelativePath, getBasename, getDirPath, getAbsoluteFilePath };
export { getRelativePath, getBasename, getAbsoluteFilePath };

View File

@@ -0,0 +1,134 @@
// Mock platform module for Unix before importing path utilities
jest.mock('platform', () => ({
os: {
family: 'Unix'
}
}));
import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
describe('Path Utilities - Unix Platform', () => {
describe('getRelativePath', () => {
it('should return relative path between two directories', () => {
expect(getRelativePath('/users/john/projects', '/users/john/projects/app')).toBe('app');
});
it('should return "." when both paths are the same', () => {
expect(getRelativePath('/users/john/projects', '/users/john/projects')).toBe('.');
});
it('should return parent directory path', () => {
expect(getRelativePath('/users/john/projects', '/users/john/docs/readme.md')).toBe('../docs/readme.md');
});
it('should return nested subdirectory path', () => {
expect(getRelativePath('/users/john/projects', '/users/john/projects/src/components')).toBe('src/components');
});
it('should handle null/undefined inputs', () => {
expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects');
expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects');
});
});
describe('getBasename', () => {
it('should return filename from relative path', () => {
expect(getBasename('/users/john/projects', '../docs/readme.md')).toBe('readme.md');
});
it('should return filename from subdirectory path', () => {
expect(getBasename('/users/john/projects', 'subfolder/config.json')).toBe('config.json');
});
it('should return filename from direct file path', () => {
expect(getBasename('/users/john/projects', 'package.json')).toBe('package.json');
});
it('should return directory name for parent directory', () => {
expect(getBasename('/users/john/projects', '..')).toBe('john');
});
it('should return directory name for current directory', () => {
expect(getBasename('/users/john/projects', '.')).toBe('projects');
});
it('should return filename from nested path', () => {
expect(getBasename('/users/john/projects', 'src/components/Button.jsx')).toBe('Button.jsx');
});
it('should return empty string for falsy relativePath', () => {
expect(getBasename('/users/john/projects', '')).toBe('');
expect(getBasename('/users/john/projects', null)).toBe('');
expect(getBasename('/users/john/projects', undefined)).toBe('');
});
it('should handle complex relative paths', () => {
expect(getBasename('/users/john/projects', '../../docs/api/spec.md')).toBe('spec.md');
});
it('should handle paths with multiple extensions', () => {
expect(getBasename('/users/john/projects', 'src/utils/common/path.spec.js')).toBe('path.spec.js');
});
});
describe('getAbsoluteFilePath', () => {
it('should resolve relative file path against collection path', () => {
const result = getAbsoluteFilePath('/users/john/collections', 'config/settings.json');
expect(result).toBe('/users/john/collections/config/settings.json');
});
it('should handle nested file paths', () => {
const result = getAbsoluteFilePath('/users/john/collections', 'api/v1/users.json');
expect(result).toBe('/users/john/collections/api/v1/users.json');
});
it('should handle parent directory references', () => {
const result = getAbsoluteFilePath('/users/john/collections/api', '../shared/config.json');
expect(result).toBe('/users/john/collections/shared/config.json');
});
it('should handle empty file path', () => {
const result = getAbsoluteFilePath('/users/john/collections', '');
expect(result).toBe('/users/john/collections');
});
it('should handle current directory reference', () => {
const result = getAbsoluteFilePath('/users/john/collections', '.');
expect(result).toBe('/users/john/collections');
});
it('should handle previous directory reference', () => {
const result = getAbsoluteFilePath('/users/john/collections', '..');
expect(result).toBe('/users/john');
});
it('should handle root file path', () => {
const result = getAbsoluteFilePath('/users/john/collections', '/absolute/path/file.json');
expect(result).toBe('/absolute/path/file.json');
});
it('should handle current directory reference', () => {
const result = getAbsoluteFilePath('/users/john/collections', './local-file.json');
expect(result).toBe('/users/john/collections/local-file.json');
});
});
describe('Edge cases', () => {
it('should handle very long paths', () => {
const longPath = '/users/john/projects/' + 'a'.repeat(100);
const result = getBasename(longPath, 'file.txt');
expect(result).toBe('file.txt');
});
it('should handle paths with special characters', () => {
expect(getBasename('/users/john/projects', 'file with spaces.txt')).toBe('file with spaces.txt');
expect(getBasename('/users/john/projects', 'file-with-dashes.txt')).toBe('file-with-dashes.txt');
expect(getBasename('/users/john/projects', 'file_with_underscores.txt')).toBe('file_with_underscores.txt');
});
it('should handle paths with unicode characters', () => {
expect(getBasename('/users/john/projects', 'файл.txt')).toBe('файл.txt');
expect(getBasename('/users/john/projects', '文件.txt')).toBe('文件.txt');
});
});
});

View File

@@ -0,0 +1,310 @@
// Mock platform module for Windows before importing path utilities
jest.mock('platform', () => ({
os: {
family: 'Windows'
}
}));
import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
describe('Path Utilities - Windows Platform', () => {
describe('getRelativePath', () => {
it('should return relative path between two directories', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App');
});
it('should return "." when both paths are the same', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects')).toBe('.');
});
it('should return parent directory path', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Docs\\readme.md', false)).toBe('..\\Docs\\readme.md');
});
it('should return nested subdirectory path', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components', false)).toBe('src\\components');
});
describe('with posixify enabled', () => {
it('should convert backslashes to forward slashes', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App');
});
it('should convert parent directory path to posix format', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Docs\\readme.md')).toBe('../Docs/readme.md');
});
it('should convert nested subdirectory path to posix format', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components')).toBe('src/components');
});
it('should handle complex paths with posixify', () => {
expect(getRelativePath('C:\\Users\\John\\Projects\\api', 'C:\\Users\\John\\Projects\\src\\utils\\common')).toBe('../src/utils/common');
});
it('should handle deep nested paths with posixify', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components\\ui\\forms')).toBe('src/components/ui/forms');
});
it('should handle paths with multiple backslashes', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\\\components')).toBe('src/components');
});
});
});
describe('getBasename', () => {
it('should return filename from relative path', () => {
expect(getBasename('C:\\Users\\John\\Projects', '..\\Docs\\readme.md')).toBe('readme.md');
});
it('should return filename from subdirectory path', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'subfolder\\config.json')).toBe('config.json');
});
it('should return filename from direct file path', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'package.json')).toBe('package.json');
});
it('should return directory name for parent directory', () => {
expect(getBasename('C:\\Users\\John\\Projects', '..')).toBe('John');
});
it('should return directory name for current directory', () => {
expect(getBasename('C:\\Users\\John\\Projects', '.')).toBe('Projects');
});
it('should return filename from nested path', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'src\\components\\Button.jsx')).toBe('Button.jsx');
});
it('should return empty string for falsy relativePath', () => {
expect(getBasename('C:\\Users\\John\\Projects', '')).toBe('');
expect(getBasename('C:\\Users\\John\\Projects', null)).toBe('');
expect(getBasename('C:\\Users\\John\\Projects', undefined)).toBe('');
});
it('should handle complex relative paths', () => {
expect(getBasename('C:\\Users\\John\\Projects', '..\\..\\Docs\\api\\spec.md')).toBe('spec.md');
});
it('should handle paths with multiple extensions', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'src\\utils\\common\\path.spec.js')).toBe('path.spec.js');
});
});
describe('getAbsoluteFilePath', () => {
it('should resolve relative file path against collection path', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'config\\settings.json');
expect(result).toBe('C:\\Users\\John\\Collections\\config\\settings.json');
});
it('should handle nested file paths', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'api\\v1\\users.json');
expect(result).toBe('C:\\Users\\John\\Collections\\api\\v1\\users.json');
});
it('should handle parent directory references', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections\\api', '..\\shared\\config.json');
expect(result).toBe('C:\\Users\\John\\Collections\\shared\\config.json');
});
it('should handle empty file path', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '');
expect(result).toBe('C:\\Users\\John\\Collections');
});
it('should handle current directory reference', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '.');
expect(result).toBe('C:\\Users\\John\\Collections');
});
it('should handle previous directory reference', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '..');
expect(result).toBe('C:\\Users\\John');
});
it('should handle root file path', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'D:\\absolute\\path\\file.json');
expect(result).toBe('D:\\absolute\\path\\file.json');
});
it('should handle current directory reference', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '.\\local-file.json');
expect(result).toBe('C:\\Users\\John\\Collections\\local-file.json');
});
describe('with posixify enabled', () => {
it('should convert backslashes to forward slashes in resolved path', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'config\\settings.json', true);
expect(result).toBe('C:/Users/John/Collections/config/settings.json');
});
it('should handle nested file paths with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'api\\v1\\users.json', true);
expect(result).toBe('C:/Users/John/Collections/api/v1/users.json');
});
it('should handle parent directory references with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections\\api', '..\\shared\\config.json', true);
expect(result).toBe('C:/Users/John/Collections/shared/config.json');
});
it('should handle current directory reference with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '.', true);
expect(result).toBe('C:/Users/John/Collections');
});
it('should handle previous directory reference with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '..', true);
expect(result).toBe('C:/Users/John');
});
it('should handle root file path with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', 'D:\\absolute\\path\\file.json', true);
expect(result).toBe('D:/absolute/path/file.json');
});
it('should handle current directory reference with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Collections', '.\\local-file.json', true);
expect(result).toBe('C:/Users/John/Collections/local-file.json');
});
it('should handle complex nested paths with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'src\\components\\ui\\forms\\login.jsx', true);
expect(result).toBe('C:/Users/John/Projects/src/components/ui/forms/login.jsx');
});
it('should handle paths with multiple backslashes with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'src\\\\components\\button.jsx', true);
expect(result).toBe('C:/Users/John/Projects/src/components/button.jsx');
});
});
});
describe('Cross-platform path handling', () => {
describe('Windows fromPath with POSIX toPath', () => {
it('should handle Windows fromPath with POSIX toPath in getAbsoluteFilePath', () => {
// This demonstrates the current behavior
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'App/config.json');
expect(result).toBe('C:\\Users\\John\\Projects\\App\\config.json');
});
it('should handle Windows fromPath with mixed separators in getRelativePath', () => {
const result = getRelativePath('C:\\Users\\John\\Projects', 'C:/Users/John/Projects/App');
// This should work since both are Windows paths, just different separators
expect(result).toBe('App');
});
it('should handle Windows fromPath with mixed separators in getAbsoluteFilePath', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'App/config.json');
expect(result).toBe('C:\\Users\\John\\Projects\\App\\config.json');
});
it('should handle Windows fromPath with mixed separators in getAbsoluteFilePath', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', '../config.json');
expect(result).toBe('C:\\Users\\John\\config.json');
});
});
describe('Mixed path separators within same platform', () => {
it('should handle mixed separators in Windows paths for getRelativePath', () => {
const result = getRelativePath('C:/Users/John/Projects', 'C:\\Users\\John\\Projects\\App', false);
expect(result).toBe('App');
});
it('should handle mixed separators in Windows paths for getAbsoluteFilePath', () => {
const result = getAbsoluteFilePath('C:/Users/John/Projects', 'App\\config.json');
expect(result).toBe('C:\\Users\\John\\Projects\\App\\config.json');
});
it('should handle mixed separators with posixify in getRelativePath', () => {
const result = getRelativePath('C:/Users/John/Projects', 'C:\\Users\\John\\Projects\\App', true);
expect(result).toBe('App');
});
it('should handle mixed separators with posixify in getAbsoluteFilePath', () => {
const result = getAbsoluteFilePath('C:/Users/John/Projects', 'App\\config.json', true);
expect(result).toBe('C:/Users/John/Projects/App/config.json');
});
});
describe('Cross-platform with posixify', () => {
it('should normalize cross-platform paths with posixify in getRelativePath', () => {
const result = getRelativePath('C:\\Users\\John\\Projects', 'C:/Users/John/Projects/App', true);
expect(result).toBe('App');
});
it('should normalize cross-platform paths with posixify in getAbsoluteFilePath', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'App/config.json', true);
expect(result).toBe('C:/Users/John/Projects/App/config.json');
});
it('should handle complex mixed separators with posixify', () => {
const result = getAbsoluteFilePath('C:/Users/John/Projects', 'src\\components/ui\\forms', true);
expect(result).toBe('C:/Users/John/Projects/src/components/ui/forms');
});
it('should handle POSIX absolute paths with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', './absolute/path/file.json', true);
expect(result).toBe('C:/Users/John/Projects/absolute/path/file.json');
});
it('should handle Windows absolute paths with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', '.\\absolute\\path\\file.json', true);
expect(result).toBe('C:/Users/John/Projects/absolute/path/file.json');
});
});
});
describe('Edge cases', () => {
it('should handle very long paths', () => {
const longPath = 'C:\\Users\\John\\Projects\\' + 'a'.repeat(100);
const result = getBasename(longPath, 'file.txt');
expect(result).toBe('file.txt');
});
it('should handle paths with special characters', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'file with spaces.txt')).toBe('file with spaces.txt');
expect(getBasename('C:\\Users\\John\\Projects', 'file-with-dashes.txt')).toBe('file-with-dashes.txt');
expect(getBasename('C:\\Users\\John\\Projects', 'file_with_underscores.txt')).toBe('file_with_underscores.txt');
});
it('should handle paths with unicode characters', () => {
expect(getBasename('C:\\Users\\John\\Projects', 'файл.txt')).toBe('файл.txt');
expect(getBasename('C:\\Users\\John\\Projects', '文件.txt')).toBe('文件.txt');
});
describe('with posixify enabled', () => {
it('should handle very long paths with posixify', () => {
const longPath = 'C:\\Users\\John\\Projects\\' + 'a'.repeat(100);
const result = getAbsoluteFilePath(longPath, 'file.txt', true);
expect(result).toBe(`C:/Users/John/Projects/${'a'.repeat(100)}/file.txt`);
});
it('should handle paths with special characters and posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'file with spaces.txt', true);
expect(result).toBe('C:/Users/John/Projects/file with spaces.txt');
});
it('should handle paths with unicode characters and posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'файл.txt', true);
expect(result).toBe('C:/Users/John/Projects/файл.txt');
});
it('should handle mixed path separators with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', 'src/components\\ui/forms', true);
expect(result).toBe('C:/Users/John/Projects/src/components/ui/forms');
});
it('should handle empty relative path with posixify', () => {
const result = getAbsoluteFilePath('C:\\Users\\John\\Projects', '', true);
expect(result).toBe('C:/Users/John/Projects');
});
it('should handle relative path with posixify', () => {
const result = getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\файл.txt', true);
expect(result).toBe('файл.txt');
});
});
});
});

View File

@@ -9,12 +9,7 @@
* @returns {Promise<boolean>} - True if file exists, false otherwise
*/
export const existsSync = async (filePath) => {
try {
return await window?.ipcRenderer?.invoke('renderer:exists-sync', filePath);
} catch (error) {
console.error('Error checking if file exists:', error);
return false;
}
return await window.ipcRenderer.invoke('renderer:exists-sync', filePath);
};
/**
@@ -24,10 +19,18 @@ export const existsSync = async (filePath) => {
* @returns {Promise<string>} - The resolved absolute path
*/
export const resolvePath = async (relativePath, basePath) => {
try {
return await window?.ipcRenderer?.invoke('renderer:resolve-path', relativePath, basePath);
} catch (error) {
console.error('Error resolving path:', error);
return relativePath;
}
};
return await window.ipcRenderer.invoke('renderer:resolve-path', relativePath, basePath);
};
export const browseDirectory = async (pathname) => {
return await window.ipcRenderer.invoke('renderer:browse-directory', pathname);
};
/**
* Check if a path is a directory
* @param {string} dirPath - The directory path to check
* @returns {Promise<boolean>} - True if path is a directory, false otherwise
*/
export const isDirectory = async (dirPath) => {
return await window.ipcRenderer.invoke('renderer:is-directory', dirPath);
};

View File

@@ -1,3 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
import { resolvePath } from 'utils/filesystem';
export const sendNetworkRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
@@ -139,20 +142,29 @@ export const endGrpcStream = async (requestId) => {
});
};
export const loadGrpcMethodsFromProtoFile = async (filePath, includeDirs = []) => {
return new Promise((resolve, reject) => {
export const loadGrpcMethodsFromProtoFile = async (filePath, collection = null) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:load-methods-proto', { filePath, includeDirs }).then(resolve).catch(reject);
// Extract import paths from collection's gRPC config if available
let importPaths = [];
if (collection) {
const config = cloneDeep(collection.brunoConfig);
if (config.protobuf && config.protobuf.importPaths) {
// Use Promise.all to wait for all resolvePath calls to complete
const enabledImportPaths = config.protobuf.importPaths.filter((importPath) => importPath.enabled);
importPaths = await Promise.all(enabledImportPaths.map((importPath) => {
return resolvePath(importPath.path, collection.pathname);
}));
}
}
ipcRenderer.invoke('grpc:load-methods-proto', { filePath, includeDirs: importPaths }).then(resolve).catch(reject);
});
};
// export const getGrpcMethodsFromReflection = async (request, collection, environment, runtimeVariables) => {
// return new Promise((resolve, reject) => {
// const { ipcRenderer } = window;
// ipcRenderer.invoke('grpc:load-methods-reflection', { request, collection, environment, runtimeVariables }).then(resolve).catch(reject);
// });
// };
export const cancelGrpcConnection = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;

View File

@@ -21,6 +21,7 @@ const EnvironmentSecretsStore = require('../store/env-secrets');
const UiStateSnapshot = require('../store/ui-state-snapshot');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transfomBrunoConfig');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
@@ -175,7 +176,28 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const brunoConfig = JSON.parse(content);
let brunoConfig = JSON.parse(content);
/*
* This is a temporary migration to convert grpc to protobuf
* This got added on september 18, 2025
* TODO: Remove this after 1st January, 2026
*/
if (brunoConfig.grpc) {
brunoConfig.protobuf = brunoConfig.grpc;
delete brunoConfig.grpc;
const stringifiedConfig = JSON.stringify(brunoConfig, null, 2);
fs.writeFileSync(pathname, stringifiedConfig);
const payload = {
collectionUid,
brunoConfig: brunoConfig
};
win.webContents.send('main:bruno-config-update', payload);
}
// Transform the config to add existence checks for protobuf files and import paths
brunoConfig = await transformBrunoConfigAfterRead(brunoConfig, collectionPath);
setBrunoConfig(collectionUid, brunoConfig);
} catch (err) {
@@ -375,7 +397,10 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const brunoConfig = JSON.parse(content);
let brunoConfig = JSON.parse(content);
// Transform the config to add existence checks for protobuf files and import paths
brunoConfig = await transformBrunoConfigAfterRead(brunoConfig, collectionPath);
const payload = {
collectionUid,

View File

@@ -4,6 +4,7 @@ const { dialog, ipcMain } = require('electron');
const Yup = require('yup');
const { isDirectory, normalizeAndResolvePath, getCollectionStats } = require('../utils/filesystem');
const { generateUidBasedOnHash } = require('../utils/common');
const { transformBrunoConfigAfterRead } = require('../utils/transfomBrunoConfig');
// todo: bruno.json config schema validation errors must be propagated to the UI
const configSchema = Yup.object({
@@ -90,6 +91,9 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
brunoConfig.ignore = ['node_modules', '.git'];
}
// Transform the config to add existence checks for protobuf files and import paths
brunoConfig = await transformBrunoConfigAfterRead(brunoConfig, collectionPath);
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;

View File

@@ -56,6 +56,7 @@ const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network/cert-utils');
const collectionWatcher = require('../app/collection-watcher');
const { transformBrunoConfigBeforeSave } = require('../utils/transfomBrunoConfig');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -845,8 +846,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:update-bruno-config', async (event, brunoConfig, collectionPath, collectionUid) => {
try {
const transformedBrunoConfig = transformBrunoConfigBeforeSave(brunoConfig);
const brunoConfigPath = path.join(collectionPath, 'bruno.json');
const content = await stringifyJson(brunoConfig);
const content = await stringifyJson(transformedBrunoConfig);
await writeFile(brunoConfigPath, content);
} catch (error) {
return Promise.reject(error);

View File

@@ -1,17 +1,16 @@
const { ipcMain } = require('electron');
const fs = require('fs');
const fsPromises = require('fs/promises');
const path = require('node:path');
const {
browseDirectory,
browseFiles,
normalizeAndResolvePath,
isFile
isFile,
isDirectory
} = require('../utils/filesystem');
const registerFilesystemIpc = (mainWindow) => {
// Browse directory
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
try {
return await browseDirectory(mainWindow);
@@ -20,7 +19,6 @@ const registerFilesystemIpc = (mainWindow) => {
}
});
// Browse files
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
return await browseFiles(mainWindow, filters, properties);
@@ -29,7 +27,6 @@ const registerFilesystemIpc = (mainWindow) => {
}
});
// Check if file exists
ipcMain.handle('renderer:exists-sync', async (_, filePath) => {
try {
const normalizedPath = normalizeAndResolvePath(filePath);
@@ -39,7 +36,6 @@ const registerFilesystemIpc = (mainWindow) => {
}
});
// Resolve path
ipcMain.handle('renderer:resolve-path', async (_, relativePath, basePath) => {
try {
const resolvedPath = path.resolve(basePath, relativePath);
@@ -48,6 +44,10 @@ const registerFilesystemIpc = (mainWindow) => {
return relativePath;
}
});
ipcMain.handle('renderer:is-directory', async (_, pathname) => {
return isDirectory(pathname);
});
};
module.exports = registerFilesystemIpc;

View File

@@ -0,0 +1,70 @@
const path = require('path');
const { isFile, isDirectory } = require('./filesystem');
function transformBrunoConfigBeforeSave(brunoConfig) {
// remove exists from importPaths and protoFiles
if (brunoConfig.protobuf?.importPaths) {
brunoConfig.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((importPath) => {
delete importPath.exists;
return importPath;
});
}
if (brunoConfig.protobuf?.protoFiles) {
brunoConfig.protobuf.protoFiles = brunoConfig.protobuf.protoFiles.map((protoFile) => {
delete protoFile.exists;
return protoFile;
});
}
return brunoConfig;
}
async function transformBrunoConfigAfterRead(brunoConfig, collectionPathname) {
// add exists to importPaths and protoFiles by checking actual file/directory existence
if (brunoConfig.protobuf?.importPaths) {
brunoConfig.protobuf.importPaths = await Promise.all(brunoConfig.protobuf.importPaths.map(async (importPath) => {
try {
// Resolve the relative path against the collection pathname
const absolutePath = path.resolve(collectionPathname, importPath.path);
// Check if it's a directory
const exists = isDirectory(absolutePath);
return {
...importPath,
exists
};
} catch (error) {
return {
...importPath,
exists: false
};
}
}));
}
if (brunoConfig.protobuf?.protoFiles) {
brunoConfig.protobuf.protoFiles = await Promise.all(brunoConfig.protobuf.protoFiles.map(async (protoFile) => {
try {
// Resolve the relative path against the collection pathname
const absolutePath = path.resolve(collectionPathname, protoFile.path);
// Check if it's a file
const exists = isFile(absolutePath);
return {
...protoFile,
exists
};
} catch (error) {
return {
...protoFile,
exists: false
};
}
}));
}
return brunoConfig;
}
module.exports = {
transformBrunoConfigBeforeSave,
transformBrunoConfigAfterRead
};

View File

@@ -0,0 +1,8 @@
meta {
name: HelloService
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,18 @@
meta {
name: sayHello
type: grpc
seq: 1
}
grpc {
url: {{host}}
body: grpc
auth: inherit
}
body:grpc {
name: message 1
content: '''
{}
'''
}

View File

@@ -0,0 +1,41 @@
{
"version": "1",
"name": "Grpcbin",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"size": 0.001827239990234375,
"filesCount": 10,
"protobuf": {
"protoFiles": [
{
"path": "../protos/services/invalid-file-path.proto",
"type": "file"
},
{
"path": "../protos/services/product.proto",
"type": "file"
},
{
"path": "../protos/services/order.proto",
"type": "file"
}
],
"importPaths": [
{
"path": "../protos/invalid-import-path",
"enabled": true
},
{
"path": "../protos/types",
"enabled": false
},
{
"path": ".",
"enabled": true
}
]
}
}

View File

View File

@@ -0,0 +1,3 @@
vars {
host: grpc://grpcb.in:9000
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/protobuf/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/protobuf/collection"
],
"preferences": {
"beta": {
"grpc": true,
"nodevm": false
}
}
}

View File

@@ -0,0 +1,154 @@
import { test, expect } from '../../playwright';
import { closeAllCollections } from '../utils/page';
test.describe('manage protofile', () => {
test.afterAll(async ({ page }) => {
await closeAllCollections(page);
});
test('protofiles, import paths from bruno.json are visible in the protobuf settings', async ({ pageWithUserData: page }) => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' }).click();
await page.getByRole('tab', { name: 'Protobuf' }).click();
// Wait for protobuf settings to load
const protobufProtoFilesSection = page.getByTestId('protobuf-proto-files-section');
await protobufProtoFilesSection.waitFor({ state: 'visible' });
// Check proto files table
const protoFilesTable = page.getByTestId('protobuf-proto-files-table');
await expect(protoFilesTable).toBeVisible();
const file = page.getByRole('cell', { name: 'product.proto', exact: true });
expect(file).toBeVisible();
const filePath = page.getByRole('cell', { name: '../protos/services/product.proto' });
expect(filePath).toBeVisible();
// Check import paths table
const importPathsTable = page.getByTestId('protobuf-import-paths-table');
await expect(importPathsTable).toBeVisible();
const importPath = page.getByRole('cell', { name: '../protos/types', exact: true });
await expect(importPath).toBeVisible();
const invalidFilePath = page.getByRole('cell', { name: 'invalid-file-path.proto', exact: true });
await expect(invalidFilePath).toBeVisible();
const invalidImportPath = page.getByRole('cell', { name: '../protos/invalid-import-path', exact: true });
await expect(invalidImportPath).toBeVisible();
const collectionPathAsImportPath = page.getByRole('cell', { name: '.', exact: true });
const collectionPathName = page.getByRole('cell', { name: 'collection', exact: true });
// Invalid messages using test IDs
const invalidProtoFilesMessage = page.getByTestId('protobuf-invalid-files-message');
const invalidImportPathsMessage = page.getByTestId('protobuf-invalid-import-paths-message');
await expect(invalidProtoFilesMessage).toBeVisible();
await expect(invalidImportPathsMessage).toBeVisible();
await expect(collectionPathAsImportPath).toBeVisible();
await expect(collectionPathName).toBeVisible();
await page.getByRole('row', { name: 'invalid-file-path.proto' }).getByTestId('protobuf-remove-file-button').click();
await expect(page.getByRole('cell', { name: 'invalid-file-path.proto', exact: true })).not.toBeVisible();
await expect(invalidProtoFilesMessage).not.toBeVisible();
await page.getByRole('row', { name: '../protos/invalid-import-path' }).getByTestId('protobuf-remove-import-path-button').click();
await expect(page.getByRole('cell', { name: '../protos/invalid-import-path', exact: true })).not.toBeVisible();
await expect(invalidImportPathsMessage).not.toBeVisible();
});
test('order.proto loads methods successfully when selected', async ({ pageWithUserData: page }) => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' }).click();
await page.getByText('HelloService').click();
await page.getByText('SayHello').click();
// Wait for gRPC query URL container to load
const grpcQueryUrlContainer = page.getByTestId('grpc-query-url-container');
await grpcQueryUrlContainer.waitFor({ state: 'visible' });
await page.getByText('Using Reflection').click();
await page.getByText('Proto FileReflection').click();
// Use more specific selector for proto file selection
await page.locator('div').filter({ hasText: /^order\.proto\.\.\/protos\/services\/order\.proto$/ }).first().click();
// Use test ID for method selection
const grpcMethodsDropdown = page.getByTestId('grpc-methods-dropdown');
await grpcMethodsDropdown.click();
const method = page.getByTestId('grpc-method-item').filter({ hasText: /^CreateOrderunary$/ }).first();
await expect(method).toBeVisible();
await method.click();
await page.getByRole('tab', { name: 'gRPC sayHello' }).getByRole('img').click();
await page.getByRole('button', { name: 'Don\'t Save' }).click();
});
test('product.proto fails to load methods when selected', async ({ pageWithUserData: page }) => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' }).click();
await page.getByText('HelloService').click();
await page.getByText('SayHello').click();
// Wait for gRPC query URL container to load
const grpcQueryUrlContainer = page.getByTestId('grpc-query-url-container');
await grpcQueryUrlContainer.waitFor({ state: 'visible' });
await page.getByText('Using Reflection').click();
await page.getByText('Proto FileReflection').click();
// Use more specific selector for proto file selection
await page.locator('div').filter({ hasText: /^product\.proto\.\.\/protos\/services\/product\.proto$/ }).first().click();
const loadedMethodsMessage = await page.getByText('Failed to load gRPC methods: Unknown error').first().isVisible();
expect(loadedMethodsMessage).toBe(true);
// Check that methods dropdown is not visible when loading fails
const methodsDropdown = page.getByTestId('grpc-methods-dropdown');
await expect(methodsDropdown).not.toBeVisible();
await page.getByRole('tab', { name: 'gRPC sayHello' }).getByRole('img').click();
await page.getByRole('button', { name: 'Don\'t Save' }).click();
});
test('product.proto successfully loads methods once import path is provided', async ({ pageWithUserData: page }) => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' }).click();
// add import path within collection setting, protobuf tab
await page.getByRole('tab', { name: 'Protobuf' }).click();
// Wait for protobuf settings to load
const protobufImportPathsSection = page.getByTestId('protobuf-import-paths-section');
await protobufImportPathsSection.waitFor({ state: 'visible' });
const importPathTable = page.getByTestId('protobuf-import-paths-table');
await expect(importPathTable).toBeVisible();
// Use test ID for checkbox
const checkbox = page.getByRole('row', { name: 'Enable this import path types' }).getByTestId('protobuf-import-path-checkbox');
await checkbox.click();
// Now test that product.proto can load methods successfully
await page.getByText('HelloService').click();
await page.getByText('SayHello').click();
// Wait for gRPC query URL container to load
const grpcQueryUrlContainer = page.getByTestId('grpc-query-url-container');
await grpcQueryUrlContainer.waitFor({ state: 'visible' });
await page.getByText('Using Reflection').click();
await page.getByText('Proto FileReflection').click();
// Use more specific selector for proto file selection
await page.locator('div').filter({ hasText: /^product\.proto\.\.\/protos\/services\/product\.proto$/ }).first().click();
const grpcMethodsDropdown = page.getByTestId('grpc-methods-dropdown');
await grpcMethodsDropdown.click();
const method = page.getByTestId('grpc-methods-list').filter({ hasText: 'CreateProductunary' }).first();
await expect(method).toBeVisible();
await method.click();
// Clean up
await page.getByRole('tab', { name: 'gRPC sayHello' }).getByRole('img').click();
await page.getByRole('button', { name: 'Don\'t Save' }).click();
});
});

View File

@@ -0,0 +1,75 @@
syntax = "proto3";
package order;
service Order {
rpc CreateOrder (OrderRequest) returns (OrderResponse);
rpc GetOrder (OrderId) returns (OrderResponse);
rpc ListOrders (ListOrdersRequest) returns (ListOrdersResponse);
rpc UpdateOrder (OrderRequest) returns (OrderResponse);
rpc DeleteOrder (OrderId) returns (DeleteOrderResponse);
// Stream of order status updates
rpc TrackOrderStatus (OrderId) returns (stream OrderStatusUpdate);
}
message OrderId {
string id = 1;
}
message OrderItem {
int32 product_id = 1;
int32 quantity = 2;
float unit_price = 3;
}
message OrderRequest {
string id = 1;
string user_id = 2;
repeated OrderItem items = 3;
string shipping_address = 4;
float total_amount = 5;
OrderStatus status = 6;
}
message OrderResponse {
string id = 1;
string user_id = 2;
repeated OrderItem items = 3;
string shipping_address = 4;
float total_amount = 5;
OrderStatus status = 6;
string created_at = 7;
string updated_at = 8;
}
enum OrderStatus {
PENDING = 0;
PROCESSING = 1;
SHIPPED = 2;
DELIVERED = 3;
CANCELLED = 4;
}
message ListOrdersRequest {
string user_id = 1;
int32 page = 2;
int32 limit = 3;
}
message ListOrdersResponse {
repeated OrderResponse orders = 1;
int32 total_count = 2;
}
message DeleteOrderResponse {
bool success = 1;
string message = 2;
}
message OrderStatusUpdate {
string order_id = 1;
OrderStatus status = 2;
string timestamp = 3;
string message = 4;
}

View File

@@ -0,0 +1,23 @@
syntax = "proto3";
package product;
import "product-message.proto";
service Product {
rpc CreateProduct (ProductItem) returns (ProductItem);
rpc ReadProduct (ProductId) returns (ProductItem);
rpc ReadProducts (VoidParam) returns (ProductItems);
rpc UpdateProduct(ProductItem) returns (ProductItem);
rpc DeleteProduct (ProductId) returns (DeleteProductResponse);
rpc CreateExampleProduct (VoidParam) returns (ProductItem);
// Server Streaming: Stream product updates to client
rpc WatchProductUpdates (ProductId) returns (stream ProductUpdate);
// Client Streaming: Batch create products
rpc BatchCreateProducts (stream ProductItem) returns (ProductBatchResponse);
// Bidirectional Streaming: Real-time price monitoring
rpc MonitorProductPrices (stream PriceAlert) returns (stream PriceUpdate);
}

View File

@@ -0,0 +1,72 @@
syntax = "proto3";
package product;
message VoidParam {}
message ProductId {
int32 id = 1;
}
enum Category {
SMARTPHONE = 0;
CAMERA = 1;
LAPTOPS = 2;
HEADPHONES = 3;
CHARGERS = 4;
SPEAKERS = 5;
TELEVISIONS = 6;
MODEMS = 7;
KEYBOARD = 8;
MICROPHONES = 9;
}
message ProductItem {
int32 id = 1;
string name = 2;
string description = 3;
float price = 4;
Category category = 5;
}
message ProductItems {
repeated ProductItem products = 1;
}
message DeleteProductResponse {
bool deleted = 1;
}
message ProductUpdate {
ProductItem product = 1;
enum UpdateType {
CREATED = 0;
MODIFIED = 1;
DELETED = 2;
}
UpdateType type = 2;
string timestamp = 3;
}
message ProductBatchResponse {
int32 success_count = 1;
repeated ProductItem failed_items = 2;
string message = 3;
}
message PriceAlert {
int32 product_id = 1;
float target_price = 2;
enum AlertType {
PRICE_ABOVE = 0;
PRICE_BELOW = 1;
}
AlertType type = 3;
}
message PriceUpdate {
int32 product_id = 1;
float current_price = 2;
bool alert_triggered = 3;
string timestamp = 4;
}