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 ( - {}} /> + {}} />