temp: temp commit to cherry pick

This commit is contained in:
Bijin A B
2026-03-16 19:11:51 +05:30
parent 83ddfc33d2
commit fa7df40615
8 changed files with 458 additions and 23 deletions

View File

@@ -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 && (
<ShareWorkspace
workspaceUid={currentWorkspace.uid}
onClose={() => setShareWorkspaceModalOpen(false)}
/>
)}
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={handleAdvancedCreateClose} />
@@ -544,7 +542,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
</div>
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<div className="dropdown-item" onClick={handleShareWorkspace}>
<div className="dropdown-icon">
<IconUpload size={16} strokeWidth={1.5} />
</div>

View File

@@ -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;

View File

@@ -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 `<!-- Fetch in Bruno Button (Workspace) -->
<div class="bruno-fetch-button" data-bruno-collection-url="${escapeHtml(gitRemoteUrl)}"></div>
<script src="${FETCH_BASE}/button.js"></script>`;
};
const getMarkdownEmbedCode = (gitRemoteUrl) => gitRemoteUrl
? `[<img src="${FETCH_BASE}/button.svg" alt="Fetch in Bruno" style="width: 130px; height: 30px;" width="128" height="32">](${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 (
<StyledWrapper>
<p className="embed-description">
Embed a Fetch in Bruno button in README&apos;s, your website, or anywhere you want to make it easy for developers to clone and run your workspace. Learn more about{' '}
<a href="https://docs.usebruno.com/to/embed-bruno-collection" target="_blank" rel="noopener noreferrer" className="opencollection-link">
Fetch in Bruno
</a>
</p>
<div className="embed-warning-box">
Creating an embedded &apos;Fetch in Bruno&apos; button requires a synchronized local and remote Git repository
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<div className="embed-section">
<p className="embed-description">
Embed a Fetch in Bruno button in README&apos;s, your website, or anywhere you want to make it easy for developers to clone and run your workspace. Learn more about{' '}
<a href="https://docs.usebruno.com/git-integration/embed-bruno-collection" target="_blank" rel="noopener noreferrer" className="opencollection-link">
Fetch in Bruno
</a>
.
</p>
<div className="embed-remote-url-card">
<IconGitBranch size={18} strokeWidth={1.5} className="embed-remote-icon" />
<span className="embed-remote-url">{gitRemoteUrl}</span>
</div>
</div>
<div className="embed-tabs-row">
<Tabs value={embedTab} onValueChange={setEmbedTab}>
<TabsList>
{EMBED_CODE_TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="embed-code-wrap">
<div className="embed-code-container code-container">
<CodeEditor
value={embedCode}
theme={displayedTheme}
mode={embedTab === 'html' ? 'text/html' : 'text/x-markdown'}
/>
</div>
<CopyToClipboard text={embedCode} onCopy={() => toast.success('Copied to clipboard!')}>
<button type="button" className="embed-copy-btn">
<IconCopy size={18} strokeWidth={1.5} />
</button>
</CopyToClipboard>
</div>
</StyledWrapper>
);
};
export default EmbedWorkspace;

View File

@@ -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 (
<div className="flex flex-col">
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
Export this workspace as a ZIP file to back up or share with others.
</p>
<div className="modal-footer">
<Button size="sm" onClick={handleExportZip} disabled={isExporting} loading={isExporting}>
{isExporting ? 'Exporting...' : 'Export as ZIP'}
</Button>
</div>
</div>
);
};
export default ExportWorkspace;

View File

@@ -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 <ExportWorkspace workspace={workspace} onClose={onClose} />;
case 'embed':
return <EmbedWorkspace workspace={workspace} />;
default:
return null;
}
};
if (!workspace) return null;
return (
<Modal size="md" title="Export Workspace" handleCancel={onClose} hideFooter>
<StyledWrapper className="flex flex-col h-full w-full">
<div className="flex w-full mb-6">
<div className="inline-flex tabs">
<div className={getTabClassname('export')} onClick={handleTabSelect('export')}>
<IconUpload size={18} strokeWidth={1.5} className="mr-2" />
Export
</div>
<div className={getTabClassname('embed')} onClick={handleTabSelect('embed')}>
<IconCode size={18} strokeWidth={1.5} className="mr-2" />
Embed
</div>
</div>
</div>
{renderTabContent()}
</StyledWrapper>
</Modal>
);
};
export default ShareWorkspace;

View File

@@ -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;

View File

@@ -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
};

View File

@@ -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');
});
});
});