From 1cc3a6432a4d1641bf860d2b1043ce1d48ba9f39 Mon Sep 17 00:00:00 2001 From: sanish chirayath Date: Tue, 7 Oct 2025 12:47:16 +0530 Subject: [PATCH] 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 --- .../CollectionSettings/Grpc/index.js | 263 ------ .../{Grpc => Protobuf}/StyledWrapper.js | 0 .../CollectionSettings/Protobuf/index.js | 336 +++++++ .../components/CollectionSettings/index.js | 14 +- .../src/components/Dropdown/index.js | 12 +- .../src/components/Icons/Grpc/index.js | 2 +- .../components/RequestPane/GrpcBody/index.js | 464 +++++----- .../GrpcQueryUrl/GrpcurlModal/index.js | 64 ++ .../GrpcQueryUrl/MethodDropdown/index.js | 131 +++ .../GrpcQueryUrl/ProtoFileDropdown/index.js | 217 +++++ .../Tabs/ImportPathsTab/StyledWrapper.js | 154 ++++ .../GrpcQueryUrl/Tabs/ImportPathsTab/index.js | 102 +++ .../Tabs/ProtoFilesTab/StyledWrapper.js | 172 ++++ .../GrpcQueryUrl/Tabs/ProtoFilesTab/index.js | 106 +++ .../Tabs/TabNavigation/StyledWrapper.js | 23 + .../GrpcQueryUrl/Tabs/TabNavigation/index.js | 35 + .../RequestPane/GrpcQueryUrl/Tabs/index.js | 3 + .../RequestPane/GrpcQueryUrl/index.js | 853 +++--------------- .../components/ToggleSwitch/StyledWrapper.js | 2 +- .../src/components/ToggleSwitch/index.js | 4 +- .../src/hooks/useProtoFileManagement/index.js | 297 ++++++ .../hooks/useReflectionManagement/index.js | 102 +++ packages/bruno-app/src/themes/dark.js | 98 ++ packages/bruno-app/src/themes/light.js | 104 +++ .../bruno-app/src/utils/collections/index.js | 14 + packages/bruno-app/src/utils/common/path.js | 161 +++- .../bruno-app/src/utils/common/path.spec.js | 134 +++ .../src/utils/common/path.windows.spec.js | 310 +++++++ packages/bruno-app/src/utils/filesystem.js | 29 +- packages/bruno-app/src/utils/network/index.js | 32 +- .../src/app/collection-watcher.js | 29 +- .../bruno-electron/src/app/collections.js | 4 + packages/bruno-electron/src/ipc/collection.js | 4 +- packages/bruno-electron/src/ipc/filesystem.js | 14 +- .../src/utils/transfomBrunoConfig.js | 70 ++ .../collection/HelloService/folder.bru | 8 + .../collection/HelloService/sayHello.bru | 18 + tests/protobuf/collection/bruno.json | 41 + tests/protobuf/collection/collection.bru | 0 .../collection/environments/GrpcEnv.bru | 3 + .../init-user-data/collection-security.json | 10 + .../protobuf/init-user-data/preferences.json | 12 + tests/protobuf/manage-protofile.spec.ts | 154 ++++ tests/protobuf/protos/services/order.proto | 75 ++ tests/protobuf/protos/services/product.proto | 23 + .../protos/types/product-message.proto | 72 ++ 46 files changed, 3484 insertions(+), 1291 deletions(-) delete mode 100644 packages/bruno-app/src/components/CollectionSettings/Grpc/index.js rename packages/bruno-app/src/components/CollectionSettings/{Grpc => Protobuf}/StyledWrapper.js (100%) create mode 100644 packages/bruno-app/src/components/CollectionSettings/Protobuf/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/GrpcurlModal/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/MethodDropdown/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/index.js create mode 100644 packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/index.js create mode 100644 packages/bruno-app/src/hooks/useProtoFileManagement/index.js create mode 100644 packages/bruno-app/src/hooks/useReflectionManagement/index.js create mode 100644 packages/bruno-app/src/utils/common/path.spec.js create mode 100644 packages/bruno-app/src/utils/common/path.windows.spec.js create mode 100644 packages/bruno-electron/src/utils/transfomBrunoConfig.js create mode 100644 tests/protobuf/collection/HelloService/folder.bru create mode 100644 tests/protobuf/collection/HelloService/sayHello.bru create mode 100644 tests/protobuf/collection/bruno.json create mode 100644 tests/protobuf/collection/collection.bru create mode 100644 tests/protobuf/collection/environments/GrpcEnv.bru create mode 100644 tests/protobuf/init-user-data/collection-security.json create mode 100644 tests/protobuf/init-user-data/preferences.json create mode 100644 tests/protobuf/manage-protofile.spec.ts create mode 100644 tests/protobuf/protos/services/order.proto create mode 100644 tests/protobuf/protos/services/product.proto create mode 100644 tests/protobuf/protos/types/product-message.proto diff --git a/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js b/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js deleted file mode 100644 index db2313efd..000000000 --- a/packages/bruno-app/src/components/CollectionSettings/Grpc/index.js +++ /dev/null @@ -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 ( - -
-
- -
- {/* Hidden file input for file selection */} - - -
- {/* File selection options */} -
-
- -
-
- - {/* Divider */} -
- - {/* List of added proto files */} -
-
- - Added Proto Files ({formik.values.protoFiles.length}) -
- - {formik.values.protoFiles.length === 0 ? ( -
No proto files added yet
- ) : ( - <> - {formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && ( -
- - Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations. -
- )} -
    - {formik.values.protoFiles.map((file, index) => { - const isValid = protoFileValidity[file.path]; - return ( -
  • -
    -
    - -
    - {getBasename(file.path)} - - {getDirPath(file.path)} - -
    -
    -
    - {!isValid && ( -
    - - -
    - )} - -
    -
    -
  • - ); - })} -
- - )} -
-
-
-
- -
- -
-
-
- ); -}; - -export default GrpcSettings; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Grpc/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js similarity index 100% rename from packages/bruno-app/src/components/CollectionSettings/Grpc/StyledWrapper.js rename to packages/bruno-app/src/components/CollectionSettings/Protobuf/StyledWrapper.js diff --git a/packages/bruno-app/src/components/CollectionSettings/Protobuf/index.js b/packages/bruno-app/src/components/CollectionSettings/Protobuf/index.js new file mode 100644 index 000000000..845bea6c6 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Protobuf/index.js @@ -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 ( + + {/* Hidden file input for file selection */} + + + {/* Proto Files Section */} +
+
+
+ +
+
+ +
+ {protoFiles.some((file) => !file.exists) && ( +
+ + Some proto files cannot be found. Use the replace option to update their locations. +
+ )} + + + + + + + + + + + {protoFiles.length === 0 ? ( + + + + ) : ( + protoFiles.map((file, index) => { + const isValid = file.exists; + + return ( + + + + + + ); + }) + )} + +
+ File + + Path + + Actions +
+
+ + No proto files added +
+
+
+ + + {getBasename(collection.pathname, file.path)} + + {!isValid && } +
+
+
+ {file.path} +
+
+
+ {!isValid && ( + + )} + +
+
+ +
+
+ + {/* Import Paths Section */} +
+
+
+ +
+
+ +
+ {importPaths.some((path) => !path.exists) && ( +
+ + Some import paths cannot be found at their specified locations. +
+ )} + + + + + + + + + + + + {importPaths.length === 0 ? ( + + + + ) : ( + importPaths.map((importPath, index) => { + const isValid = importPath.exists; + + return ( + + + + + + + ); + }) + )} + +
+ + Directory + + Path + + Actions +
+
+ + No import paths added +
+
+ 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" + /> + +
+ + + {getBasename(collection.pathname, importPath.path)} + + {!isValid && } +
+
+
+ {importPath.path} +
+
+
+ {!isValid && ( + + )} + +
+
+ +
+
+ +
+ ); +}; + +export default ProtobufSettings; diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index 4f90126e6..e78fa373d 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -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 ; + case 'protobuf': { + return ; } } }; @@ -172,9 +172,9 @@ const CollectionSettings = ({ collection }) => { Client Certificates {clientCertConfig.length > 0 && } -
setTab('grpc')}> - gRPC - {grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && } +
setTab('protobuf')}> + Protobuf + {protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && }
{getTabPanel(tab)}
diff --git a/packages/bruno-app/src/components/Dropdown/index.js b/packages/bruno-app/src/components/Dropdown/index.js index c4eccce64..7c2d0f98a 100644 --- a/packages/bruno-app/src/components/Dropdown/index.js +++ b/packages/bruno-app/src/components/Dropdown/index.js @@ -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 ( {icon} diff --git a/packages/bruno-app/src/components/Icons/Grpc/index.js b/packages/bruno-app/src/components/Icons/Grpc/index.js index 1424ce288..dc7b8ce3e 100644 --- a/packages/bruno-app/src/components/Icons/Grpc/index.js +++ b/packages/bruno-app/src/components/Icons/Grpc/index.js @@ -90,4 +90,4 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className -); \ No newline at end of file +); diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js index ce7cdebd9..e17bdb074 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js @@ -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 ( -
-
-
- {isCollapsed ? - : - - } - {`Message ${canClientStream ? index + 1 : ''}`} -
-
e.stopPropagation()}> - - - - - - - - - {canClientStream && ( - - - - )} - - {index > 0 && ( - - - - )} -
+ // 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 ( +
+
+
+ {isCollapsed + ? + : } + {`Message ${canClientStream ? index + 1 : ''}`} +
+
e.stopPropagation()}> + + + + + + + + + {canClientStream && ( + + + + )} + + {index > 0 && ( + + + + )}
- - {!isCollapsed && ( -
- -
- )}
- ) -} + + {!isCollapsed && ( +
+ +
+ )} +
+ ); +}; 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 }) => {

No gRPC messages available

-
+ )} + size="lg" + hideFooter={true} + > +
+
+
+
+ +
+ +
+
+
+ + ); +}; + +export default GrpcurlModal; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/MethodDropdown/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/MethodDropdown/index.js new file mode 100644 index 000000000..14fcb75a6 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/MethodDropdown/index.js @@ -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 ; + case 'client-streaming': + return ; + case 'server-streaming': + return ; + case 'bidi-streaming': + return ; + default: + return ; + } + }; + + const MethodsDropdownIcon = forwardRef((props, ref) => { + return ( +
+ {selectedGrpcMethod &&
{getIconForMethodType(selectedGrpcMethod.type)}
} + + {selectedGrpcMethod ? ( + + {selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path} + + ) : ( + Select Method + )} + + +
+ ); + }); + + const handleGrpcMethodSelect = (method) => { + const methodType = method.type; + onMethodSelect({ path: method.path, type: methodType }); + }; + + if (!grpcMethods || grpcMethods.length === 0) { + return null; + } + + return ( +
+ } placement="bottom-end" style={{ maxWidth: 'unset' }}> +
+ {Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => ( +
+
+ {serviceName || 'Default Service'} +
+
+ {methods.map((method, methodIndex) => ( +
handleGrpcMethodSelect(method)} + data-testid="grpc-method-item" + > +
+
+ {getIconForMethodType(method.type)} +
+
+
+ {method.methodName} +
+
+ {method.type} +
+
+
+
+ ))} +
+
+ ))} +
+
+
+ ); +}; + +export default MethodDropdown; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/index.js new file mode 100644 index 000000000..a51aef472 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/index.js @@ -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 ( +
setShowProtoDropdown((prev) => !prev)} data-testid="grpc-proto-file-dropdown-icon"> + {isReflectionMode ? (<> + ) : ( + + )} + + {isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(collection.pathname, protoFilePath) : 'Select Proto File')} + + +
+ ); + }); + + return ( +
+ } + placement="bottom-end" + visible={showProtoDropdown} + onClickOutside={() => setShowProtoDropdown(false)} + data-testid="grpc-proto-file-dropdown" + > +
+
+
+ Mode +
+ + Proto File + + + + Reflection + +
+
+
+ + {!isReflectionMode && ( + + )} + + {!isReflectionMode && ( + <> + {activeTab === 'protofiles' && ( + + )} + + {activeTab === 'importpaths' && ( + + )} + + )} + + {isReflectionMode && ( +
+
+ Using server reflection to discover gRPC methods. +
+
+ )} +
+
+
+ ); +}; + +export default ProtoFileDropdown; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/StyledWrapper.js new file mode 100644 index 000000000..f7d7afefa --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/index.js new file mode 100644 index 000000000..8624fe7eb --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ImportPathsTab/index.js @@ -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 ( + + {collectionImportPaths && collectionImportPaths.length > 0 && ( +
+
+
From Collection Settings
+ +
+ + {invalidImportPaths.length > 0 && ( +
+

+ + Some import paths could not be found. + {' '} + +

+
+ )} + +
+ {collectionImportPaths.map((importPath, index) => { + const isInvalid = !importPath.exists; + + return ( +
+
+
+
+ onToggleImportPath(index)} + className="checkbox" + title={importPath.enabled ? 'Import path enabled' : 'Import path disabled'} + /> +
+ +
+ {importPath.path} + {isInvalid && ( + + + + )} +
+
+
+
+ ); + })} +
+
+ )} + + {(!collectionImportPaths || collectionImportPaths.length === 0) && ( +
+
+ No import paths configured in collection settings +
+
+ )} + +
+ +
+
+ ); +}; + +export default ImportPathsTab; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/StyledWrapper.js new file mode 100644 index 000000000..05e535f7c --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/index.js new file mode 100644 index 000000000..bdc4965f4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/ProtoFilesTab/index.js @@ -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 ( + + {collectionProtoFiles && collectionProtoFiles.length > 0 && ( +
+
+
From Collection Settings
+ +
+ + {invalidProtoFiles.length > 0 && ( +
+

+ + Some proto files could not be found. + {' '} + +

+
+ )} + +
+ {collectionProtoFiles.map((protoFile, index) => { + const isSelected = protoFilePath === protoFile.path; + const isInvalid = !protoFile.exists; + + return ( +
{ + if (!isInvalid) { + onSelectCollectionProtoFile(protoFile); + } + }} + > +
+
+ +
+
+
+ {getBasename(collection.pathname, protoFile.path)} + {isInvalid && ( + + + + )} +
+
+ {protoFile.path} +
+
+
+
+ ); + })} +
+
+ )} + + {(!collectionProtoFiles || collectionProtoFiles.length === 0) && ( +
+
+ No proto files configured in collection settings +
+
+ )} + +
+ +
+
+ ); +}; + +export default ProtoFilesTab; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/StyledWrapper.js new file mode 100644 index 000000000..9d5314f1b --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/index.js new file mode 100644 index 000000000..78e44f848 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/TabNavigation/index.js @@ -0,0 +1,35 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const TabNavigation = ({ activeTab, onTabChange, collectionProtoFiles, collectionImportPaths }) => { + return ( + +
+ + +
+
+ ); +}; + +export default TabNavigation; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/index.js new file mode 100644 index 000000000..7069135c8 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/Tabs/index.js @@ -0,0 +1,3 @@ +export { default as TabNavigation } from './TabNavigation'; +export { default as ProtoFilesTab } from './ProtoFilesTab'; +export { default as ImportPathsTab } from './ImportPathsTab'; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js index 150736ae3..39f34996f 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js @@ -1,15 +1,11 @@ -import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react'; -import get from 'lodash/get'; +import React, { useState, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { requestUrlChanged, updateRequestMethod, updateRequestProtoPath } from 'providers/ReduxStore/slices/collections'; -import { saveRequest, browseFiles, loadGrpcMethodsFromReflection, openCollectionSettings, generateGrpcurlCommand } from 'providers/ReduxStore/slices/collections/actions'; +import { saveRequest, generateGrpcurlCommand } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { isMacOS } from 'utils/common/platform'; -import { getRelativePath, getBasename, getAbsoluteFilePath } from 'utils/common/path'; -import useLocalStorage from 'hooks/useLocalStorage/index'; import StyledWrapper from './StyledWrapper'; -import ToggleSwitch from 'components/ToggleSwitch/index'; import { IconX, IconCheck, @@ -17,173 +13,78 @@ import { IconDeviceFloppy, IconArrowRight, IconCode, - IconFile, - IconChevronDown, - IconSettings, - IconAlertCircle, - IconCopy } from '@tabler/icons'; import toast from 'react-hot-toast'; import { - loadGrpcMethodsFromProtoFile, cancelGrpcConnection, endGrpcConnection } from 'utils/network/index'; -import Dropdown from 'components/Dropdown/index'; -import { - IconGrpcUnary, - IconGrpcClientStreaming, - IconGrpcServerStreaming, - IconGrpcBidiStreaming -} from 'components/Icons/Grpc'; -import Modal from 'components/Modal/index'; -import CodeEditor from 'components/CodeEditor'; +import GrpcurlModal from './GrpcurlModal'; import { debounce } from 'lodash'; import { getPropertyFromDraftOrRequest } from 'utils/collections'; -import { existsSync } from 'utils/filesystem'; +import useReflectionManagement from 'hooks/useReflectionManagement/index'; +import useProtoFileManagement from 'hooks/useProtoFileManagement/index'; +import MethodDropdown from './MethodDropdown'; +import ProtoFileDropdown from './ProtoFileDropdown'; -// Constants for gRPC method types const STREAMING_METHOD_TYPES = ['client-streaming', 'server-streaming', 'bidi-streaming']; const CLIENT_STREAMING_METHOD_TYPES = ['client-streaming', 'bidi-streaming']; -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 ( - - Generate gRPCurl Command - BETA -
- } - size="lg" - hideFooter={true} - > -
-
-
-
- -
- -
-
-
- - ); -}; - const GrpcQueryUrl = ({ item, collection, handleRun }) => { const { theme, storedTheme } = useTheme(); const dispatch = useDispatch(); const method = getPropertyFromDraftOrRequest(item, 'request.method'); const type = getPropertyFromDraftOrRequest(item, 'request.type'); const url = getPropertyFromDraftOrRequest(item, 'request.url', ''); - const protoPath = getPropertyFromDraftOrRequest(item, 'request.protoPath'); const isMac = isMacOS(); const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; const editorRef = useRef(null); const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); - const [protoFilePath, setProtoFilePath] = useState(protoPath); + const [grpcMethods, setGrpcMethods] = useState([]); - const [isLoadingMethods, setIsLoadingMethods] = useState(false); const [selectedGrpcMethod, setSelectedGrpcMethod] = useState({ path: method, type: type }); - const methodDropdownRef = useRef(); - const protoDropdownRef = useRef(); - const haveFetchedMethodsRef = useRef(false); + const [isReflectionMode, setIsReflectionMode] = useState(false); + const [protoFilePath, setProtoFilePath] = useState(item?.request?.protoPath || ''); const [showGrpcurlModal, setShowGrpcurlModal] = useState(false); const [grpcurlCommand, setGrpcurlCommand] = useState(''); - const [isReflectionMode, setIsReflectionMode] = useState(false); - const collectionProtoFiles = get(collection, 'brunoConfig.grpc.protoFiles', []); - const [reflectionCache, setReflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {}); - const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {}); - const fileExistsCache = useRef(new Map()); const [showProtoDropdown, setShowProtoDropdown] = useState(false); - const fileExists = useCallback(async (filePath) => { - if (!filePath) return false; - - if (fileExistsCache.current.has(filePath)) { - return fileExistsCache.current.get(filePath); + const methodDropdownRef = useRef(null); + const protoDropdownRef = useRef(null); + const haveFetchedMethodsRef = useRef(false); + + const protoFileManagement = useProtoFileManagement(collection, protoFilePath); + const reflectionManagement = useReflectionManagement(item, collection.uid); + + const onMethodSelect = ({ path, type }) => { + if (isConnectionActive) { + cancelGrpcConnection(item.uid) + .then(() => { + toast.success('gRPC connection cancelled'); + }) + .catch((err) => { + console.error('Failed to cancel gRPC connection:', err); + }); } - try { - const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); - const exists = await existsSync(absolutePath); - fileExistsCache.current.set(filePath, exists); - return exists; - } catch (error) { - console.error('Error checking if file exists:', error); - return false; - } - }, [collection.pathname]); - - const [collectionProtoFilesExistence, setCollectionProtoFilesExistence] = useState([]); - - useEffect(() => { - const fetchCollectionProtoFilesExistence = async () => { - if (!collectionProtoFiles) return; - const existence = await Promise.all(collectionProtoFiles.map(async (protoFile) => { - const absolutePath = getAbsoluteFilePath(protoFile.path, collection.pathname); - const exists = await fileExists(absolutePath) - return { - path: protoFile.path, - absolutePath, - exists - } - })); - setCollectionProtoFilesExistence(existence); - }; - fetchCollectionProtoFilesExistence(); - }, [fileExists]); - - const invalidProtoFiles = useMemo(() => { - return collectionProtoFilesExistence.filter(file => !file.exists); - }, [collectionProtoFilesExistence]); - - const currentProtoFileExists = useMemo(() => { - return fileExists(protoFilePath); - }, [protoFilePath, fileExists]); + dispatch(updateRequestMethod({ + method: path, + methodType: type, + itemUid: item.uid, + collectionUid: collection.uid + })); + }; const onMethodDropdownCreate = (ref) => (methodDropdownRef.current = ref); const onProtoDropdownCreate = (ref) => (protoDropdownRef.current = ref); - const isStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); const isClientStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && CLIENT_STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); - const onSave = (finalValue) => { + const onSave = () => { dispatch(saveRequest(item.uid, collection.uid)); }; @@ -202,7 +103,6 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { }) ); - // Restore cursor position only if URL was trimmed if (finalUrl !== value) { setTimeout(() => { if (editor) { @@ -211,123 +111,81 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { }, 0); } - if(!protoFilePath && value) { + if (!protoFilePath && value) { setIsReflectionMode(true); handleReflection(finalUrl); } }; - const onMethodSelect = ({ path, type }) => { - if (isConnectionActive) { - cancelGrpcConnection(item.uid) - .then(() => { - toast.success('gRPC connection cancelled'); - }) - .catch((err) => { - console.error('Failed to cancel gRPC connection:', err); - }); + const handleReflection = async (url, isManualRefresh = false) => { + const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh); + + if (error) { + toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); + return; } - dispatch( - updateRequestMethod({ - method: path, - methodType: type, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); + setGrpcMethods(methods); + setProtoFilePath(''); + setIsReflectionMode(true); + + dispatch(updateRequestProtoPath({ + protoPath: '', + itemUid: item.uid, + collectionUid: collection.uid + })); + + if (methods && methods.length > 0) { + toast.success(`Loaded ${methods.length} gRPC methods from reflection`); + } + + if (methods && methods.length > 0) { + const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else if (selectedGrpcMethod) { + const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: currentMethod.type + }); + } + } + } }; - const handleReflection = async (url, isManualRefresh = false) => { - if (!url) return; + const handleProtoFileLoad = async (filePath, isManualRefresh = false) => { + const { methods, error } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh); - const cachedMethods = reflectionCache[url]; - if (!isManualRefresh && cachedMethods && !isLoadingMethods) { - setGrpcMethods(cachedMethods); - setProtoFilePath(''); - setIsReflectionMode(true); - const isDuplicateSave = !item.request.protoPath; - if (!isDuplicateSave) { - dispatch(updateRequestProtoPath({ - protoPath: '', - itemUid: item.uid, - collectionUid: collection.uid - })); - } - - if (cachedMethods && cachedMethods.length > 0) { - const haveSelectedMethod = - selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); - if (!haveSelectedMethod) { - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - } else if (selectedGrpcMethod) { - // Update the method type for the currently selected method to ensure it matches - const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); - if (currentMethod) { - const methodType = currentMethod.type; - setSelectedGrpcMethod({ - path: selectedGrpcMethod.path, - type: methodType - }); - } - } - return; - } + if (error) { + console.error('Failed to load gRPC methods:', error); + toast.error('Failed to load gRPC methods'); + setGrpcMethods([]); + return; } - setIsLoadingMethods(true); - try { - const { methods, error } = await dispatch(loadGrpcMethodsFromReflection(item, collection.uid, url)); + setProtoFilePath(filePath); + setGrpcMethods(methods); + setIsReflectionMode(false); - if (error) { - console.error('Error loading gRPC methods:', error); - toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); - return; - } + toast.success(`Loaded ${methods.length} gRPC methods from proto file`); - // Cache the methods for this URL - setReflectionCache(prevCache => ({ - ...prevCache, - [url]: methods - })); - - setGrpcMethods(methods); - setProtoFilePath(''); - setIsReflectionMode(true); - const isDuplicateSave = !item.request.protoPath; - if (!isDuplicateSave) { - dispatch(updateRequestProtoPath({ - protoPath: '', - itemUid: item.uid, - collectionUid: collection.uid - })); - } - - if (methods && methods.length > 0) { - const haveSelectedMethod = - selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); - if (!haveSelectedMethod) { - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - } else if (selectedGrpcMethod) { - // Update the method type for the currently selected method to ensure it matches - const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); - if (currentMethod) { - const methodType = currentMethod.type; - setSelectedGrpcMethod({ - path: selectedGrpcMethod.path, - type: methodType - }); - } + if (methods && methods.length > 0) { + const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); + if (!haveSelectedMethod) { + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); + } else if (selectedGrpcMethod) { + const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); + if (currentMethod) { + setSelectedGrpcMethod({ + path: selectedGrpcMethod.path, + type: currentMethod.type + }); } - toast.success(`Loaded ${methods.length} gRPC methods from reflection`); } - } catch (error) { - console.error('Error loading gRPC methods:', error); - toast.error('Failed to load gRPC methods from reflection'); - } finally { - setIsLoadingMethods(false); } }; @@ -357,74 +215,8 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { } }; - // Add a new function to group methods by service - const groupMethodsByService = (methods) => { - if (!methods || !methods.length) return {}; - - const groupedMethods = {}; - - methods.forEach(method => { - // The format is "/service.ServiceName/MethodName" - const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path; - const parts = pathWithoutLeadingSlash.split('/'); - - // The service is the part before the last slash - const serviceName = parts[0] || 'Default'; - // The method name is the part after the last slash - const methodName = parts[1] || method.path; - - // Store the extracted method name for easier display - const enhancedMethod = { - ...method, - serviceName, - methodName - }; - - if (!groupedMethods[serviceName]) { - groupedMethods[serviceName] = []; - } - - groupedMethods[serviceName].push(enhancedMethod); - }); - - return groupedMethods; - }; - - const MethodsDropdownIcon = forwardRef((props, ref) => { - return ( -
- {selectedGrpcMethod &&
{getIconForMethodType(selectedGrpcMethod.type)}
} - - {selectedGrpcMethod ? ( - - {selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path} - - ) : ( - Select Method - )} - - -
- ); - }); - - const ProtoFileDropdownIcon = forwardRef((props, ref) => { - return ( -
setShowProtoDropdown(prev => !prev)}> - {isReflectionMode ? (<> - ) : ( - - )} - - {isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(protoFilePath) : 'Select Proto File')} - - -
- ); - }); - const handleGrpcMethodSelect = (method) => { - const methodType = method.type + const methodType = method.type; setSelectedGrpcMethod({ path: method.path, type: methodType @@ -432,21 +224,6 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { onMethodSelect({ path: method.path, type: methodType }); }; - const getIconForMethodType = (type) => { - switch (type) { - case 'unary': - return ; - case 'client-streaming': - return ; - case 'server-streaming': - return ; - case 'bidi-streaming': - return ; - default: - return ; - } - }; - const handleCancelConnection = (e) => { e.stopPropagation(); @@ -473,197 +250,49 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { }); }; - const handleSelectCollectionProtoFile = (protoFile) => { - try { - if (!protoFile) { - toast.error('No proto file selected'); - return; - } - - // Get the absolute path from the relative path - const absolutePath = protoFile.absolutePath; - - if (!protoFile.exists) { - toast.error(`Proto file not found: ${protoFile.path}`); - return; - } - - setProtoFilePath(protoFile.path); - setIsReflectionMode(false); - - dispatch(updateRequestProtoPath({ - protoPath: protoFile.path, - itemUid: item.uid, - collectionUid: collection.uid - })); - - loadMethodsFromProtoFile(absolutePath); - } catch (error) { - console.error('Error selecting collection proto file:', error); - toast.error('Failed to select collection proto file'); - } - }; - - const handleResetProtoFile = () => { - setProtoFilePath(''); - setIsReflectionMode(true); - const isDuplicateSave = !item.request.protoPath; - if (!isDuplicateSave) { + const handleReflectionModeToggle = (e) => { + e.stopPropagation(); + e.preventDefault(); + setIsReflectionMode(!isReflectionMode); + if (!isReflectionMode) { + setProtoFilePath(''); dispatch(updateRequestProtoPath({ protoPath: '', itemUid: item.uid, collectionUid: collection.uid })); - } - setGrpcMethods([]); - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - toast.success('Proto file reset'); - }; - - const loadMethodsFromProtoFile = async (filePath, isManualRefresh = false) => { - if (!filePath) { - toast.error('No proto file selected'); - return; - }; - const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); - - // Check if we have cached methods for this proto file - const cachedMethods = protofileCache[absolutePath]; - if (cachedMethods && !isLoadingMethods && !isManualRefresh) { - setGrpcMethods(cachedMethods); - - if (cachedMethods && cachedMethods.length > 0) { - // Check if currently selected method is still valid - const haveSelectedMethod = - selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); - if (!haveSelectedMethod) { - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - } else { - // Update the method type for the currently selected method to ensure it matches - const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); - if (currentMethod) { - const methodType = currentMethod.type; - setSelectedGrpcMethod({ - path: selectedGrpcMethod.path, - type: methodType - }); - } - } + if (url) { + handleReflection(url); } - return; + } else { + setGrpcMethods([]); + setSelectedGrpcMethod(null); + onMethodSelect({ path: '', type: '' }); } - - setIsLoadingMethods(true); - try { - const { methods, error } = await loadGrpcMethodsFromProtoFile(absolutePath); - - if (error) { - console.error('Error loading gRPC methods:', error); - toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); - return; - } - - // Cache the methods for this proto file - setProtofileCache(prevCache => ({ - ...prevCache, - [absolutePath]: methods - })); - - setGrpcMethods(methods); - - if (methods && methods.length > 0) { - toast.success(`Loaded ${methods.length} gRPC methods from proto file`); - - // Check if currently selected method is still valid - const haveSelectedMethod = - selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); - if (!haveSelectedMethod) { - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - } else { - // Update the method type for the currently selected method to ensure it matches - const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); - if (currentMethod) { - const methodType = currentMethod.type; - setSelectedGrpcMethod({ - path: selectedGrpcMethod.path, - type: methodType - }); - } - } - } else { - toast.warning('No gRPC methods found in proto file'); - } - } catch (err) { - console.error('Error loading gRPC methods:', err); - toast.error('Failed to load gRPC methods from proto file'); - } finally { - setIsLoadingMethods(false); - } - }; - - const handleSelectProtoFile = (e) => { - e.stopPropagation(); - const filters = [{ name: 'Proto Files', extensions: ['proto'] }]; - - dispatch(browseFiles(filters, [''])) - .then((filePaths) => { - if (filePaths && filePaths.length > 0) { - const filePath = filePaths[0]; - const relativePath = getRelativePath(filePath, collection.pathname); - setProtoFilePath(relativePath); - setIsReflectionMode(false); - - dispatch(updateRequestProtoPath({ - protoPath: relativePath, - itemUid: item.uid, - collectionUid: collection.uid - })); - - // Load methods from the newly selected proto file - const absolutePath = getAbsoluteFilePath(relativePath, collection.pathname); - loadMethodsFromProtoFile(absolutePath); - } - }) - .catch((err) => { - console.error('Error selecting proto file:', err); - toast.error('Failed to select proto file'); - }); - }; - - const handleOpenCollectionGrpc = () => { - dispatch(openCollectionSettings(collection.uid, 'grpc')); }; const debouncedOnUrlChange = debounce(onUrlChange, 1000); useEffect(() => { - fileExistsCache.current.clear(); - }, [collection.pathname]); - - useEffect(() => { - if(haveFetchedMethodsRef.current) { + if (haveFetchedMethodsRef.current) { return; } haveFetchedMethodsRef.current = true; - if(protoFilePath) { + if (protoFilePath) { setIsReflectionMode(false); - loadMethodsFromProtoFile(protoFilePath); + handleProtoFileLoad(protoFilePath); return; } if (!url) return; setIsReflectionMode(true); handleReflection(url); - }, []); return ( - +
-
+
gRPC
@@ -680,246 +309,24 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { item={item} /> - {grpcMethods && grpcMethods.length > 0 && ( -
- } placement="bottom-end" style={{ maxWidth: "unset" }}> -
- {Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => ( -
-
- {serviceName || 'Default Service'} -
-
- {methods.map((method, methodIndex) => ( -
handleGrpcMethodSelect(method)} - > -
-
{getIconForMethodType(method.type)}
-
-
{method.methodName}
-
{method.type}
-
-
-
- ))} -
-
- ))} -
-
-
- )} +
-
- } - placement="bottom-end" - visible={showProtoDropdown} - onClickOutside={() => setShowProtoDropdown(false)} - > -
-
-

{isReflectionMode ? "Using Reflection" : "Select Proto File"}

-
- - {/* Mode Toggle */} -
-
- Mode -
- - Proto File - - { - e.stopPropagation(); - e.preventDefault(); - setIsReflectionMode(!isReflectionMode); - if (!isReflectionMode) { - // Switching to reflection mode - setProtoFilePath(''); - dispatch(updateRequestProtoPath({ - protoPath: '', - itemUid: item.uid, - collectionUid: collection.uid - })); - if (url) { - handleReflection(url); - } - } else { - // Switching to proto file mode - setGrpcMethods([]); - setSelectedGrpcMethod(null); - onMethodSelect({ path: '', type: '' }); - } - }} - size="2xs" - /> - - Reflection - -
-
-
- - {!isReflectionMode && ( - <> - {collectionProtoFiles && collectionProtoFiles.length > 0 && ( -
-
-
From Collection Settings
- -
- - {invalidProtoFiles.length > 0 && ( -
-

- - Some proto files could not be found. -

-
- )} - -
- {collectionProtoFilesExistence.map((protoFile, index) => { - const isSelected = protoFilePath === protoFile.absolutePath; - const isInvalid = !protoFile.exists; - - return ( -
{ - if (!isInvalid) { - setShowProtoDropdown(false); - handleSelectCollectionProtoFile(protoFile); - } - }} - > -
-
- -
-
- {getBasename(protoFile.absolutePath)} - {isInvalid && ( - - - - )} -
-
{protoFile.path}
-
-
-
-
- ); - })} -
-
- )} - - {collectionProtoFiles && collectionProtoFiles.length > 0 && ( -
- )} - - {protoFilePath && !collectionProtoFilesExistence.some(pf => - pf.absolutePath === protoFilePath - ) && ( -
-
Current Proto File
- {!currentProtoFileExists && ( -
-

- - Selected proto file not found. Please select a valid proto file from collection settings or browse for a new one. -

-
- )} -
-
-
- -
-
- {getBasename(protoFilePath)} - {!currentProtoFileExists && ( - - - - )} -
-
{protoFilePath}
-
-
-
- -
-
-
- -
- )} - -
- -
- - )} - - {isReflectionMode && ( -
-
- Using server reflection to discover gRPC methods. -
-
- )} -
-
-
+
{ if (isReflectionMode) { handleReflection(url, true); } else if (protoFilePath) { - loadMethodsFromProtoFile(protoFilePath, true); + handleProtoFileLoad(protoFilePath, true); } else { toast.error('No proto file selected'); } @@ -938,7 +345,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} - className={`${isLoadingMethods ? 'animate-spin' : 'cursor-pointer'}`} + className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`} /> {isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'} diff --git a/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js index d4216860a..cf6bb9e92 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js +++ b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js index bb3679038..299d77582 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/index.js +++ b/packages/bruno-app/src/components/ToggleSwitch/index.js @@ -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 ( - {}} /> + {}} />