From fa7df4061566a5b4e5c15673c809902d6e1a46d5 Mon Sep 17 00:00:00 2001 From: Bijin A B Date: Mon, 16 Mar 2026 19:11:51 +0530 Subject: [PATCH] temp: temp commit to cherry pick --- .../RequestTabs/CollectionHeader/index.js | 28 ++--- .../EmbedWorkspace/StyledWrapper.js | 92 ++++++++++++++ .../ShareWorkspace/EmbedWorkspace/index.js | 112 ++++++++++++++++++ .../ShareWorkspace/ExportWorkspace/index.js | 42 +++++++ .../src/components/ShareWorkspace/index.js | 57 +++++++++ .../ReduxStore/slices/workspaces/actions.js | 2 +- packages/bruno-electron/src/utils/deeplink.js | 53 +++++++-- .../bruno-electron/src/utils/deeplink.spec.js | 95 +++++++++++++++ 8 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/index.js create mode 100644 packages/bruno-app/src/components/ShareWorkspace/ExportWorkspace/index.js create mode 100644 packages/bruno-app/src/components/ShareWorkspace/index.js create mode 100644 packages/bruno-electron/src/utils/deeplink.spec.js diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index d3139801a..e5bd726e7 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -15,7 +15,7 @@ import { IconUpload } from '@tabler/icons'; import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; -import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; +import { switchWorkspace, renameWorkspaceAction, shareWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; @@ -25,6 +25,7 @@ import Dropdown from 'components/Dropdown'; import MenuDropdown from 'ui/MenuDropdown'; import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace'; +import ShareWorkspace from 'components/ShareWorkspace'; import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; import ToolHint from 'components/ToolHint'; import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; @@ -55,6 +56,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { const [workspaceNameError, setWorkspaceNameError] = useState(''); const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); + const [shareWorkspaceModalOpen, setShareWorkspaceModalOpen] = useState(false); const switcherRef = useRef(); const workspaceActionsRef = useRef(); @@ -256,20 +258,10 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { } }; - const handleExportWorkspace = () => { + const handleShareWorkspace = () => { workspaceActionsRef.current?.hide(); - const uid = currentWorkspace?.uid; - if (!uid) return; - - dispatch(exportWorkspaceAction(uid)) - .then((result) => { - if (!result?.canceled) { - toast.success('Workspace exported successfully'); - } - }) - .catch((error) => { - toast.error(error?.message || 'Error exporting workspace'); - }); + if (!currentWorkspace?.uid) return; + setShareWorkspaceModalOpen(true); }; const validateWorkspaceName = (name) => { @@ -401,6 +393,12 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { onClose={() => setCloseWorkspaceModalOpen(false)} /> )} + {shareWorkspaceModalOpen && currentWorkspace?.uid && ( + setShareWorkspaceModalOpen(false)} + /> + )} {createWorkspaceModalOpen && ( @@ -544,7 +542,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { {getRevealInFolderLabel()} -
+
diff --git a/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/StyledWrapper.js b/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/StyledWrapper.js new file mode 100644 index 000000000..43fff4a33 --- /dev/null +++ b/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/StyledWrapper.js @@ -0,0 +1,92 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .opencollection-link { + color: ${(props) => props.theme.textLink}; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + .embed-description { + font-size: 0.875rem; + color: ${(props) => props.theme.colors.text.body}; + margin-bottom: 0.5rem; + } + + .embed-section { + margin-bottom: 0; + } + + .embed-remote-url-card { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin-top: 1rem; + margin-bottom: 1rem; + border-radius: ${(props) => props.theme.border.radius.base}; + background-color: ${(props) => props.theme.background.secondary}; + border: 1px solid ${(props) => props.theme.border.border0}; + + .embed-remote-icon { + color: ${(props) => props.theme.colors.text.warning}; + flex-shrink: 0; + } + + .embed-remote-url { + font-size: 0.875rem; + color: ${(props) => props.theme.colors.text.body}; + word-break: break-all; + flex: 1; + } + } + + .embed-tabs-row { + margin-bottom: 1rem; + } + + .embed-code-wrap { + position: relative; + + .embed-code-container, + .code-container { + border-radius: ${(props) => props.theme.border.radius.base}; + border: 1px solid ${(props) => props.theme.border.border0}; + background-color: ${(props) => props.theme.background.secondary}; + overflow: auto; + height: 150px; + width: 100%; + display: flex; + } + + .embed-copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.375rem; + border-radius: ${(props) => props.theme.border.radius.base}; + color: ${(props) => props.theme.colors.text.subtext0}; + background: transparent; + border: none; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + + &:hover { + background-color: ${(props) => props.theme.background.base}; + color: ${(props) => props.theme.colors.text.body}; + } + } + } + + .embed-warning-box { + padding: 1rem; + border-radius: ${(props) => props.theme.border.radius.base}; + background-color: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + border: 1px solid ${(props) => props.theme.status.warning.border}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/index.js b/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/index.js new file mode 100644 index 000000000..6309bc1be --- /dev/null +++ b/packages/bruno-app/src/components/ShareWorkspace/EmbedWorkspace/index.js @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react'; +import { IconCopy, IconGitBranch } from '@tabler/icons'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import toast from 'react-hot-toast'; +import CodeEditor from 'components/CodeEditor/index'; +import { useTheme } from 'providers/Theme'; +import { escapeHtml } from 'utils/response'; +import { Tabs, TabsList, TabsTrigger } from 'components/Tabs'; +import StyledWrapper from './StyledWrapper'; + +const FETCH_BASE = 'https://fetch.usebruno.com'; + +const EMBED_CODE_TABS = [ + { value: 'html', label: 'HTML' }, + { value: 'markdown', label: 'Markdown' } +]; + +// Escape so the URL can't break out of the attribute when the snippet is pasted into HTML. +const getHtmlEmbedCode = (gitRemoteUrl) => { + if (!gitRemoteUrl) return ''; + return ` +
+`; +}; + +const getMarkdownEmbedCode = (gitRemoteUrl) => gitRemoteUrl + ? `[Fetch in Bruno](${FETCH_BASE}/?url=${encodeURIComponent(gitRemoteUrl)} "target=_blank rel=noopener noreferrer")` + : ''; + +const EmbedWorkspace = ({ workspace }) => { + const { displayedTheme } = useTheme(); + const [embedTab, setEmbedTab] = useState('html'); + const [gitRemoteUrl, setGitRemoteUrl] = useState(''); + + useEffect(() => { + if (!workspace?.pathname || !window.ipcRenderer) return; + window.ipcRenderer + .invoke('renderer:get-collection-git-details', workspace.pathname) + .then((data) => { + console.log('data', data); + if (data?.gitRootPath && data?.gitRepoUrl) { + setGitRemoteUrl(data.gitRepoUrl.trim()); + } + }) + .catch(() => {}); + }, [workspace?.pathname]); + + const embedCode = embedTab === 'html' ? getHtmlEmbedCode(gitRemoteUrl) : getMarkdownEmbedCode(gitRemoteUrl); + + if (!gitRemoteUrl) { + return ( + +

+ Embed a Fetch in Bruno button in README's, your website, or anywhere you want to make it easy for developers to clone and run your workspace. Learn more about{' '} + + Fetch in Bruno + +

+
+ ⚠️ Creating an embedded 'Fetch in Bruno' button requires a synchronized local and remote Git repository +
+
+ ); + } + + return ( + +
+

+ Embed a Fetch in Bruno button in README's, your website, or anywhere you want to make it easy for developers to clone and run your workspace. Learn more about{' '} + + Fetch in Bruno + + . +

+
+ + {gitRemoteUrl} +
+
+ +
+ + + {EMBED_CODE_TABS.map((tab) => ( + + {tab.label} + + ))} + + +
+ +
+
+ +
+ toast.success('Copied to clipboard!')}> + + +
+
+ ); +}; + +export default EmbedWorkspace; diff --git a/packages/bruno-app/src/components/ShareWorkspace/ExportWorkspace/index.js b/packages/bruno-app/src/components/ShareWorkspace/ExportWorkspace/index.js new file mode 100644 index 000000000..6a58da717 --- /dev/null +++ b/packages/bruno-app/src/components/ShareWorkspace/ExportWorkspace/index.js @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Button from 'ui/Button'; +import toast from 'react-hot-toast'; +import { shareWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; + +const ExportWorkspace = ({ workspace, onClose }) => { + const dispatch = useDispatch(); + const [isExporting, setIsExporting] = useState(false); + + const handleExportZip = async () => { + if (!workspace?.uid || isExporting) return; + + setIsExporting(true); + try { + const result = await dispatch(shareWorkspaceAction(workspace.uid)); + if (!result?.canceled) { + toast.success('Workspace exported successfully'); + onClose(); + } + } catch (err) { + toast.error(err?.message || 'Error exporting workspace'); + } finally { + setIsExporting(false); + } + }; + + return ( +
+

+ Export this workspace as a ZIP file to back up or share with others. +

+
+ +
+
+ ); +}; + +export default ExportWorkspace; diff --git a/packages/bruno-app/src/components/ShareWorkspace/index.js b/packages/bruno-app/src/components/ShareWorkspace/index.js new file mode 100644 index 000000000..6b96e5764 --- /dev/null +++ b/packages/bruno-app/src/components/ShareWorkspace/index.js @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { IconUpload, IconCode } from '@tabler/icons'; +import Modal from 'components/Modal'; +import StyledWrapper from 'components/ShareCollection/StyledWrapper'; +import classnames from 'classnames'; +import ExportWorkspace from './ExportWorkspace'; +import EmbedWorkspace from './EmbedWorkspace'; + +const ShareWorkspace = ({ onClose, workspaceUid }) => { + const workspaces = useSelector((state) => state.workspaces.workspaces); + const workspace = workspaces.find((w) => w.uid === workspaceUid); + const [tab, setTab] = useState('export'); + + const handleTabSelect = (value) => (e) => setTab(value); + + const getTabClassname = (tabName) => { + return classnames(`flex tab items-center py-2 px-4 ${tabName}`, { + active: tabName === tab + }); + }; + + const renderTabContent = () => { + switch (tab) { + case 'export': + return ; + case 'embed': + return ; + default: + return null; + } + }; + + if (!workspace) return null; + + return ( + + +
+
+
+ + Export +
+
+ + Embed +
+
+
+ {renderTabContent()} +
+
+ ); +}; + +export default ShareWorkspace; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 0028353d0..705fcb653 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -835,7 +835,7 @@ export const copyWorkspaceEnvironment = (workspaceUid, environmentUid, newName) }; }; -export const exportWorkspaceAction = (workspaceUid) => { +export const shareWorkspaceAction = (workspaceUid) => { return async (dispatch, getState) => { try { const { workspaces } = getState().workspaces; diff --git a/packages/bruno-electron/src/utils/deeplink.js b/packages/bruno-electron/src/utils/deeplink.js index 1c1d59142..bac69148c 100644 --- a/packages/bruno-electron/src/utils/deeplink.js +++ b/packages/bruno-electron/src/utils/deeplink.js @@ -6,13 +6,47 @@ const getAppProtocolUrlFromArgv = (argv) => { }; // Handle app protocol URLs -const handleAppProtocolUrl = (url) => { - // Handle OAuth2 callback URLs - `bruno://app/oauth2/callback` - if (isOauth2Url(url)) { - handleOauth2ProtocolUrl(url); +function handleAppProtocolUrl(url, mainWindow) { + try { + const workspaceRepositoryUrl = getWorkspaceRepositoryUrl(url); + + console.log('workspaceRepositoryUrl', workspaceRepositoryUrl); + if (workspaceRepositoryUrl) { + mainWindow?.webContents?.send?.('main:bruno-workspace-git-url-import', workspaceRepositoryUrl); + return; + } + + // Handle OAuth2 callback URLs - `bruno://app/oauth2/callback` + if (isOauth2Url(url)) { + handleOauth2ProtocolUrl(url); + return; + } + + console.error('Unsupported Bruno Deeplink URL'); + } catch (error) { + console.error('Invalid protocol URL:', url, error?.message); } - return; -}; +} + +function getWorkspaceRepositoryUrl(url) { + try { + if (!url || typeof url !== 'string') { + return null; + } + + const parsedUrl = new URL(url); + const { pathname: parsedUrlPathname } = parsedUrl; + + if (parsedUrlPathname !== '/workspace/import/git') { + return null; + } + + return parsedUrl?.searchParams?.get?.('url') || null; + } catch (error) { + console.error('Failed to parse deep link URL:', error?.message); + return null; + } +} const isOauth2Url = (url) => { try { @@ -27,4 +61,9 @@ const isOauth2Url = (url) => { return false; }; -module.exports = { handleAppProtocolUrl, getAppProtocolUrlFromArgv }; +module.exports = { + getAppProtocolUrlFromArgv, + handleAppProtocolUrl, + getWorkspaceRepositoryUrl, + isOauth2Url +}; diff --git a/packages/bruno-electron/src/utils/deeplink.spec.js b/packages/bruno-electron/src/utils/deeplink.spec.js new file mode 100644 index 000000000..3512545be --- /dev/null +++ b/packages/bruno-electron/src/utils/deeplink.spec.js @@ -0,0 +1,95 @@ +const { getCollectionRepositoryUrl, getWorkspaceRepositoryUrl, getAppProtocolUrlFromArgv, handleAppProtocolUrl, getOpenApiSpecUrl } = require('./deeplink'); + +describe('Deeplink URL Functions', () => { + describe('getAppProtocolUrlFromArgv', () => { + it('should return the first valid deeplink URL from argv', () => { + const argv = ['some-command', 'bruno://app/collection/import/git?url=https://github.com/user/repo']; + expect(getAppProtocolUrlFromArgv(argv)).toBe('bruno://app/collection/import/git?url=https://github.com/user/repo'); + }); + + it('should return undefined if no valid deeplink URL is found', () => { + const argv = ['some-command', 'random-string']; + expect(getAppProtocolUrlFromArgv(argv)).toBeUndefined(); + }); + }); + + describe('getWorkspaceRepositoryUrl', () => { + it('should extract the repository URL from a valid workspace deeplink URL', () => { + const url = 'bruno://app/workspace/import/git?url=https://github.com/user/workspace-repo'; + expect(getWorkspaceRepositoryUrl(url)).toBe('https://github.com/user/workspace-repo'); + }); + + it('should return null for null input', () => { + expect(getWorkspaceRepositoryUrl(null)).toBeNull(); + }); + + it('should return null for non-string input', () => { + expect(getWorkspaceRepositoryUrl(42)).toBeNull(); + }); + + it('should return null for collection path', () => { + const url = 'bruno://app/collection/import/git?url=https://github.com/user/repo'; + expect(getWorkspaceRepositoryUrl(url)).toBeNull(); + }); + + it('should return null for invalid workspace path', () => { + const url = 'bruno://app/workspace/invalid/path?url=https://github.com/user/repo'; + expect(getWorkspaceRepositoryUrl(url)).toBeNull(); + }); + + it('should return null if no URL parameter is present', () => { + const url = 'bruno://app/workspace/import/git'; + expect(getWorkspaceRepositoryUrl(url)).toBeNull(); + }); + }); + + describe('handleAppProtocolUrl', () => { + let mockSend; + + beforeEach(() => { + mockSend = jest.fn(); + global.mainWindow = { webContents: { send: mockSend } }; + }); + + afterEach(() => { + global.mainWindow = undefined; + }); + + it('should send the extracted URL to the main process if valid', () => { + const url = 'bruno://app/collection/import/git?url=https://github.com/user/repo'; + handleAppProtocolUrl(url, global.mainWindow); + expect(mockSend).toHaveBeenCalledWith('main:bruno-collection-git-url-import', 'https://github.com/user/repo'); + }); + + it('should send workspace URL to main:bruno-workspace-git-url-import for workspace deeplink', () => { + const url = 'bruno://app/workspace/import/git?url=https://github.com/user/workspace-repo'; + handleAppProtocolUrl(url, global.mainWindow); + expect(mockSend).toHaveBeenCalledWith('main:bruno-workspace-git-url-import', 'https://github.com/user/workspace-repo'); + }); + + it('should send the extracted OpenAPI spec URL to the main process if valid', () => { + const url = 'bruno://app/collection/import/openapi?url=https://example.com/api-spec.json'; + handleAppProtocolUrl(url, global.mainWindow); + expect(mockSend).toHaveBeenCalledWith('main:bruno-openapi-spec-url-import', 'https://example.com/api-spec.json'); + }); + + it('should log an error for an unsupported deeplink URL', () => { + console.error = jest.fn(); + const url = 'bruno://app/collection/invalid/path?url=https://github.com/user/repo'; + handleAppProtocolUrl(url); + expect(console.error).toHaveBeenCalledWith('Unsupported Bruno Deeplink URL'); + }); + + it('should log an error for an invalid URL', () => { + console.error = jest.fn(); + const url = 'invalid-url'; + handleAppProtocolUrl(url); + expect(console.error).toHaveBeenCalledTimes(5); + expect(console.error).toHaveBeenNthCalledWith(1, 'Failed to parse deep link URL:', 'Invalid URL'); + expect(console.error).toHaveBeenNthCalledWith(2, 'Failed to parse deep link URL:', 'Invalid URL'); + expect(console.error).toHaveBeenNthCalledWith(3, 'Failed to parse deep link URL:', 'Invalid URL'); + expect(console.error).toHaveBeenNthCalledWith(4, 'Failed to parse deep link URL:', 'Invalid URL'); + expect(console.error).toHaveBeenNthCalledWith(5, 'Unsupported Bruno Deeplink URL'); + }); + }); +});