mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: integrate Git remote for collections (#7879)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.git-collection-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-left: 4px;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
opacity: 0.7;
|
||||
|
||||
.git-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
|
||||
.git-collection-name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.collection-actions {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
opacity: 0.9;
|
||||
|
||||
.collection-actions {
|
||||
visibility: visible;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { IconBrandGit, IconCopy, IconDots, IconUnlink } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import RemoveGitRemote from 'components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GitRemoteCollectionRow = ({ entry }) => {
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
const menuDropdownRef = useRef(null);
|
||||
const [showCloneModal, setShowCloneModal] = useState(false);
|
||||
const [showRemoveGitModal, setShowRemoveGitModal] = useState(false);
|
||||
|
||||
const openCloneModal = () => setShowCloneModal(true);
|
||||
const closeCloneModal = () => setShowCloneModal(false);
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(entry.remote);
|
||||
toast.success('Git URL copied');
|
||||
} catch (e) {
|
||||
toast.error('Failed to copy URL');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
event.preventDefault();
|
||||
menuDropdownRef.current?.show();
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'clone-git',
|
||||
leftSection: IconBrandGit,
|
||||
label: 'Clone from Git',
|
||||
onClick: openCloneModal
|
||||
},
|
||||
{
|
||||
id: 'copy-url',
|
||||
leftSection: IconCopy,
|
||||
label: 'Copy Git URL',
|
||||
onClick: handleCopyUrl
|
||||
},
|
||||
{
|
||||
id: 'remove-git-remote',
|
||||
leftSection: IconUnlink,
|
||||
label: 'Remove Git Remote',
|
||||
onClick: () => setShowRemoveGitModal(true)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{showCloneModal && (
|
||||
<CloneGitRepository
|
||||
onClose={closeCloneModal}
|
||||
onFinish={closeCloneModal}
|
||||
collectionRepositoryUrl={entry.remote}
|
||||
/>
|
||||
)}
|
||||
{showRemoveGitModal && (
|
||||
<RemoveGitRemote
|
||||
collectionPath={entry.path}
|
||||
collectionName={entry.name}
|
||||
remoteUrl={entry.remote}
|
||||
onClose={() => setShowRemoveGitModal(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="git-collection-row"
|
||||
onClick={openCloneModal}
|
||||
onContextMenu={handleRightClick}
|
||||
title={`${entry.name} — click to clone from ${entry.remote}`}
|
||||
data-testid="sidebar-git-collection-row"
|
||||
>
|
||||
<div className="flex flex-grow items-center overflow-hidden">
|
||||
<span className="git-badge ml-1" aria-hidden="true">
|
||||
<IconBrandGit size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="git-collection-name w-full">{entry.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pr-2" onClick={(e) => e.stopPropagation()}>
|
||||
<MenuDropdown
|
||||
ref={menuDropdownRef}
|
||||
items={menuItems}
|
||||
placement="bottom-start"
|
||||
appendTo={dropdownContainerRef?.current || document.body}
|
||||
popperOptions={{ strategy: 'fixed' }}
|
||||
>
|
||||
<ActionIcon className="collection-actions">
|
||||
<IconDots size={18} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitRemoteCollectionRow;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Collection from './Collection';
|
||||
import GitRemoteCollectionRow from './GitRemoteCollectionRow';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CreateOrOpenCollection from './CreateOrOpenCollection';
|
||||
import CollectionSearch from './CollectionSearch/index';
|
||||
@@ -14,19 +15,35 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
// Build the sidebar list in workspace.yml order. Each entry is either a fully
|
||||
// loaded collection (rendered via <Collection />) or, for non-default workspaces,
|
||||
// a "ghost" git-backed entry whose local folder is missing (rendered via
|
||||
// <GitRemoteCollectionRow /> so the user can click to clone it).
|
||||
const sidebarEntries = useMemo(() => {
|
||||
if (!activeWorkspace?.collections?.length) return [];
|
||||
|
||||
return collections.filter((c) => {
|
||||
if (isScratchCollection(c, workspaces)) {
|
||||
return false;
|
||||
const loadedByPath = new Map();
|
||||
for (const c of collections) {
|
||||
if (isScratchCollection(c, workspaces)) continue;
|
||||
if (c.pathname) loadedByPath.set(normalizePath(c.pathname), c);
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const wc of activeWorkspace.collections) {
|
||||
if (!wc.path) continue;
|
||||
const loaded = loadedByPath.get(normalizePath(wc.path));
|
||||
if (loaded) {
|
||||
entries.push({ kind: 'loaded', collection: loaded, key: loaded.uid });
|
||||
} else if (wc.remote && !isDefaultWorkspace) {
|
||||
entries.push({ kind: 'ghost', entry: wc, key: `ghost:${wc.path}` });
|
||||
}
|
||||
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
|
||||
});
|
||||
}, [activeWorkspace, collections, workspaces]);
|
||||
}
|
||||
return entries;
|
||||
}, [activeWorkspace, collections, workspaces, isDefaultWorkspace]);
|
||||
|
||||
if (!workspaceCollections || !workspaceCollections.length) {
|
||||
if (!sidebarEntries.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{isCreatingCollection && (
|
||||
@@ -55,13 +72,12 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
|
||||
onOpenAdvanced={onOpenAdvancedCreate}
|
||||
/>
|
||||
)}
|
||||
{workspaceCollections && workspaceCollections.length
|
||||
? workspaceCollections.map((c) => {
|
||||
return (
|
||||
<Collection searchText={searchText} collection={c} key={c.uid} />
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{sidebarEntries.map((entry) => {
|
||||
if (entry.kind === 'loaded') {
|
||||
return <Collection searchText={searchText} collection={entry.collection} key={entry.key} />;
|
||||
}
|
||||
return <GitRemoteCollectionRow entry={entry.entry} key={entry.key} />;
|
||||
})}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { isGitRepositoryUrl } from 'utils/git';
|
||||
import { connectCollectionToGit } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
const ConnectGitRemote = ({ collectionPath, collectionName, initialUrl = '', onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
remoteUrl: initialUrl
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
remoteUrl: Yup.string()
|
||||
.trim()
|
||||
.required('Git remote URL is required')
|
||||
.test('is-git-url', 'Enter a valid Git URL', (value) => isGitRepositoryUrl(value))
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(
|
||||
connectCollectionToGit({
|
||||
workspaceUid: activeWorkspaceUid,
|
||||
collectionPath,
|
||||
remoteUrl: values.remoteUrl.trim()
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('Git remote connected');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
// toast already handled in the thunk
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const title = initialUrl ? 'Update Git Remote' : 'Connect to Git';
|
||||
const confirmText = initialUrl ? 'Update' : 'Connect';
|
||||
|
||||
return (
|
||||
<Modal size="md" title={title} confirmText={confirmText} handleConfirm={() => formik.handleSubmit()} handleCancel={onClose}>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
{collectionName ? (
|
||||
<div className="text-xs text-muted mb-3">
|
||||
Linking <span className="font-medium">{collectionName}</span> to a remote Git repository.
|
||||
The URL is stored in <span className="font-mono">workspace.yml</span>; local files are not changed.
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<label htmlFor="remoteUrl" className="block font-medium">
|
||||
Git Remote URL
|
||||
</label>
|
||||
<input
|
||||
id="remoteUrl"
|
||||
type="text"
|
||||
name="remoteUrl"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
placeholder="https://github.com/owner/repo"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.remoteUrl || ''}
|
||||
/>
|
||||
{formik.touched.remoteUrl && formik.errors.remoteUrl ? (
|
||||
<div className="text-red-500">{formik.errors.remoteUrl}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectGitRemote;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { disconnectCollectionFromGit } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
const RemoveGitRemote = ({ collectionPath, collectionName, remoteUrl, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const handleConfirm = () => {
|
||||
dispatch(
|
||||
disconnectCollectionFromGit({
|
||||
workspaceUid: activeWorkspaceUid,
|
||||
collectionPath
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('Git remote removed');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
// toast already handled in the thunk
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Remove Git Remote"
|
||||
confirmText="Remove"
|
||||
confirmButtonColor="primary"
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Disconnect <span className="font-medium">{collectionName}</span> from its Git remote?
|
||||
</p>
|
||||
{remoteUrl ? (
|
||||
<p className="mt-2 font-mono text-xs text-muted break-all">{remoteUrl}</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
This only removes the remote URL from <span className="font-mono">workspace.yml</span>. Local files
|
||||
and any <span className="font-mono">.git</span> folder are left untouched.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoveGitRemote;
|
||||
@@ -84,6 +84,28 @@ const StyledWrapper = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collection-remote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-menu {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
|
||||
import {
|
||||
IconBox,
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconShare,
|
||||
IconDots,
|
||||
IconX,
|
||||
IconFolder,
|
||||
IconBrandGit,
|
||||
IconUnlink,
|
||||
IconCopy
|
||||
} from '@tabler/icons';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
@@ -11,6 +22,9 @@ import RemoveCollection from 'components/Sidebar/Collections/Collection/RemoveCo
|
||||
import DeleteCollection from 'components/Sidebar/Collections/Collection/DeleteCollection';
|
||||
import ShareCollection from 'components/ShareCollection';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ConnectGitRemote from './ConnectGitRemote';
|
||||
import RemoveGitRemote from './RemoveGitRemote';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionsList = ({ workspace }) => {
|
||||
@@ -23,6 +37,11 @@ const CollectionsList = ({ workspace }) => {
|
||||
const [deleteCollectionModalOpen, setDeleteCollectionModalOpen] = useState(false);
|
||||
const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
|
||||
const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
|
||||
const [gitTarget, setGitTarget] = useState(null);
|
||||
const [showConnectGitModal, setShowConnectGitModal] = useState(false);
|
||||
const [showRemoveGitModal, setShowRemoveGitModal] = useState(false);
|
||||
|
||||
const isDefaultWorkspace = workspace?.type === 'default';
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!workspace.collections || workspace.collections.length === 0) {
|
||||
@@ -162,6 +181,47 @@ const CollectionsList = ({ workspace }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleConnectGit = (collection) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Cannot connect a Git remote to a collection that is not present locally');
|
||||
return;
|
||||
}
|
||||
setGitTarget({
|
||||
path: collection.pathname,
|
||||
name: collection.name,
|
||||
remoteUrl: collection.gitRemoteUrl || ''
|
||||
});
|
||||
setShowConnectGitModal(true);
|
||||
};
|
||||
|
||||
const handleRemoveGit = (collection) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
setGitTarget({
|
||||
path: collection.pathname,
|
||||
name: collection.name,
|
||||
remoteUrl: collection.gitRemoteUrl || ''
|
||||
});
|
||||
setShowRemoveGitModal(true);
|
||||
};
|
||||
|
||||
const handleCopyGitUrl = async (collection) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
if (!collection.gitRemoteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(collection.gitRemoteUrl);
|
||||
toast.success('Git URL copied');
|
||||
} catch (e) {
|
||||
toast.error('Failed to copy URL');
|
||||
}
|
||||
};
|
||||
|
||||
const closeGitModals = () => {
|
||||
setShowConnectGitModal(false);
|
||||
setShowRemoveGitModal(false);
|
||||
setGitTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{renameCollectionModalOpen && selectedCollectionUid && (
|
||||
@@ -205,6 +265,24 @@ const CollectionsList = ({ workspace }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showConnectGitModal && gitTarget && (
|
||||
<ConnectGitRemote
|
||||
collectionPath={gitTarget.path}
|
||||
collectionName={gitTarget.name}
|
||||
initialUrl={gitTarget.remoteUrl}
|
||||
onClose={closeGitModals}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRemoveGitModal && gitTarget && (
|
||||
<RemoveGitRemote
|
||||
collectionPath={gitTarget.path}
|
||||
collectionName={gitTarget.name}
|
||||
remoteUrl={gitTarget.remoteUrl}
|
||||
onClose={closeGitModals}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="collections-list">
|
||||
{workspaceCollections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
@@ -225,8 +303,26 @@ const CollectionsList = ({ workspace }) => {
|
||||
<IconBox size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="collection-name">{collection.name}</div>
|
||||
{!isDefaultWorkspace && collection.isGitBacked && (
|
||||
<StatusBadge
|
||||
status="info"
|
||||
size="xs"
|
||||
leftSection={<IconBrandGit size={11} strokeWidth={2} />}
|
||||
>
|
||||
Git
|
||||
</StatusBadge>
|
||||
)}
|
||||
{!isDefaultWorkspace && collection.isLoaded === false && (
|
||||
<StatusBadge status="warning" size="xs">Not cloned</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="collection-path">{collection.pathname}</div>
|
||||
{!isDefaultWorkspace && collection.isGitBacked && collection.gitRemoteUrl && (
|
||||
<div className="collection-remote" title={collection.gitRemoteUrl}>
|
||||
<IconBrandGit size={12} strokeWidth={1.75} />
|
||||
<span>{collection.gitRemoteUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="collection-menu">
|
||||
<Dropdown
|
||||
@@ -266,6 +362,46 @@ const CollectionsList = ({ workspace }) => {
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<span>{getRevealInFolderLabel()}</span>
|
||||
</div>
|
||||
{!isDefaultWorkspace && (
|
||||
<>
|
||||
{collection.isGitBacked && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyGitUrl(collection);
|
||||
}}
|
||||
>
|
||||
<IconCopy size={16} strokeWidth={1.5} />
|
||||
<span>Copy Git URL</span>
|
||||
</div>
|
||||
)}
|
||||
{!collection.isGitBacked && collection.isLoaded !== false && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConnectGit(collection);
|
||||
}}
|
||||
>
|
||||
<IconBrandGit size={16} strokeWidth={1.5} />
|
||||
<span>Connect to Git</span>
|
||||
</div>
|
||||
)}
|
||||
{collection.isGitBacked && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveGit(collection);
|
||||
}}
|
||||
>
|
||||
<IconUnlink size={16} strokeWidth={1.5} />
|
||||
<span>Remove Git Remote</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -230,6 +230,51 @@ export const openWorkspaceDialog = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const connectCollectionToGit = ({ workspaceUid, collectionPath, remoteUrl }) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke(
|
||||
'renderer:connect-collection-to-git',
|
||||
workspace.pathname,
|
||||
collectionPath,
|
||||
remoteUrl
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to connect Git remote');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const disconnectCollectionFromGit = ({ workspaceUid, collectionPath }) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke(
|
||||
'renderer:disconnect-collection-from-git',
|
||||
workspace.pathname,
|
||||
collectionPath
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to remove Git remote');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath, options = {}) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
@@ -283,7 +328,8 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
|
||||
};
|
||||
|
||||
try {
|
||||
await dispatch(loadWorkspaceCollections(workspace.uid));
|
||||
const shouldRefreshCollections = workspace.collections?.some((collection) => collection.notFoundLocally);
|
||||
await dispatch(loadWorkspaceCollections(workspace.uid, shouldRefreshCollections));
|
||||
const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid));
|
||||
|
||||
if (updatedWorkspace?.collections?.length > 0) {
|
||||
@@ -292,6 +338,7 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
|
||||
);
|
||||
|
||||
const collectionPaths = updatedWorkspace.collections
|
||||
.filter((wc) => !wc.notFoundLocally)
|
||||
.map((wc) => wc.path)
|
||||
.filter((p) => p && !alreadyOpenCollections.includes(normalizePath(p)));
|
||||
|
||||
@@ -531,6 +578,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
|
||||
if (workspace?.collections?.length > 0) {
|
||||
const newCollectionPaths = workspace.collections
|
||||
.filter((workspaceCollection) => !workspaceCollection.notFoundLocally)
|
||||
.map((workspaceCollection) => workspaceCollection.path)
|
||||
.filter((collectionPath) => collectionPath && !openCollections.includes(normalizePath(collectionPath)));
|
||||
|
||||
|
||||
@@ -21,32 +21,23 @@ const {
|
||||
updateWorkspaceDocs,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
setCollectionGitRemote,
|
||||
clearCollectionGitRemote,
|
||||
reorderWorkspaceCollections,
|
||||
getWorkspaceCollections,
|
||||
resolveAndFilterWorkspaceCollections,
|
||||
normalizeCollectionEntry,
|
||||
validateWorkspacePath,
|
||||
validateWorkspaceDirectory,
|
||||
getWorkspaceUid
|
||||
} = require('../utils/workspace-config');
|
||||
|
||||
const { isValidCollectionDirectory } = require('../utils/filesystem');
|
||||
|
||||
const DEFAULT_WORKSPACE_NAME = 'My Workspace';
|
||||
|
||||
const prepareWorkspaceConfigForClient = (workspaceConfig, workspacePath, isDefault) => {
|
||||
const collections = workspaceConfig.collections || [];
|
||||
const filteredCollections = collections
|
||||
.map((collection) => {
|
||||
if (collection.path && !path.isAbsolute(collection.path)) {
|
||||
return { ...collection, path: path.resolve(workspacePath, collection.path) };
|
||||
}
|
||||
return collection;
|
||||
})
|
||||
.filter((collection) => collection.path && isValidCollectionDirectory(collection.path));
|
||||
|
||||
const config = {
|
||||
...workspaceConfig,
|
||||
collections: filteredCollections
|
||||
collections: resolveAndFilterWorkspaceCollections(workspacePath, workspaceConfig.collections)
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
@@ -582,6 +573,42 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
const broadcastWorkspaceConfig = (workspacePath, config) => {
|
||||
const workspaceUid = getWorkspaceUid(workspacePath);
|
||||
const isDefault = workspaceUid === 'default';
|
||||
const configForClient = prepareWorkspaceConfigForClient(config, workspacePath, isDefault);
|
||||
mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, configForClient);
|
||||
};
|
||||
|
||||
ipcMain.handle('renderer:connect-collection-to-git', async (event, workspacePath, collectionPath, remoteUrl) => {
|
||||
try {
|
||||
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
|
||||
throw new Error('A Git remote URL is required');
|
||||
}
|
||||
|
||||
const trimmedUrl = remoteUrl.trim();
|
||||
if (!/^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/.test(trimmedUrl)) {
|
||||
throw new Error('Invalid Git remote URL');
|
||||
}
|
||||
|
||||
const updatedConfig = await setCollectionGitRemote(workspacePath, collectionPath, trimmedUrl);
|
||||
broadcastWorkspaceConfig(workspacePath, updatedConfig);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:disconnect-collection-from-git', async (event, workspacePath, collectionPath) => {
|
||||
try {
|
||||
const updatedConfig = await clearCollectionGitRemote(workspacePath, collectionPath);
|
||||
broadcastWorkspaceConfig(workspacePath, updatedConfig);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-collection-workspaces', async (event, collectionPath) => {
|
||||
try {
|
||||
const workspacePaths = lastOpenedWorkspaces.getAll();
|
||||
|
||||
@@ -10,6 +10,8 @@ const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
|
||||
|
||||
const WORKSPACE_TYPE = 'workspace';
|
||||
const OPENCOLLECTION_VERSION = '1.0.0';
|
||||
const GITIGNORE_MANAGED_BLOCK_START = '# Bruno managed collection remotes';
|
||||
const GITIGNORE_MANAGED_BLOCK_END = '# End Bruno managed collection remotes';
|
||||
|
||||
const quoteYamlValue = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -360,6 +362,128 @@ const addCollectionToWorkspace = async (workspacePath, collection) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getCollectionGitignoreEntry = (workspacePath, collectionPath) => {
|
||||
const absolute = path.isAbsolute(collectionPath)
|
||||
? collectionPath
|
||||
: path.resolve(workspacePath, collectionPath);
|
||||
const relative = path.relative(workspacePath, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return posixifyPath(relative).replace(/\/+$/, '') + '/';
|
||||
};
|
||||
|
||||
const findGitignoreManagedBlock = (lines) => {
|
||||
const start = lines.findIndex((line) => line.trim() === GITIGNORE_MANAGED_BLOCK_START);
|
||||
if (start === -1) return null;
|
||||
|
||||
const end = lines.findIndex((line, index) => index > start && line.trim() === GITIGNORE_MANAGED_BLOCK_END);
|
||||
if (end === -1) return null;
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const addCollectionToWorkspaceGitignore = async (workspacePath, collectionPath) => {
|
||||
const entry = getCollectionGitignoreEntry(workspacePath, collectionPath);
|
||||
if (!entry) return;
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
|
||||
const lines = existing.split('\n');
|
||||
|
||||
if (lines.some((line) => line.trim() === entry)) return;
|
||||
|
||||
const managedBlock = findGitignoreManagedBlock(lines);
|
||||
if (managedBlock) {
|
||||
const updated = [...lines];
|
||||
updated.splice(managedBlock.end, 0, entry);
|
||||
await writeFile(gitignorePath, updated.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : existing + '\n';
|
||||
await writeFile(gitignorePath, `${prefix}${GITIGNORE_MANAGED_BLOCK_START}\n${entry}\n${GITIGNORE_MANAGED_BLOCK_END}\n`);
|
||||
};
|
||||
|
||||
const removeCollectionFromWorkspaceGitignore = async (workspacePath, collectionPath) => {
|
||||
const entry = getCollectionGitignoreEntry(workspacePath, collectionPath);
|
||||
if (!entry) return;
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (!fs.existsSync(gitignorePath)) return;
|
||||
|
||||
const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n');
|
||||
const managedBlock = findGitignoreManagedBlock(lines);
|
||||
if (!managedBlock) return;
|
||||
|
||||
const managedLines = lines.slice(managedBlock.start + 1, managedBlock.end);
|
||||
const filteredManagedLines = managedLines.filter((line) => line.trim() !== entry);
|
||||
if (filteredManagedLines.length === managedLines.length) return;
|
||||
|
||||
const hasManagedEntries = filteredManagedLines.some((line) => line.trim() !== '');
|
||||
const filtered = hasManagedEntries
|
||||
? [
|
||||
...lines.slice(0, managedBlock.start + 1),
|
||||
...filteredManagedLines,
|
||||
...lines.slice(managedBlock.end)
|
||||
]
|
||||
: [
|
||||
...lines.slice(0, managedBlock.start),
|
||||
...lines.slice(managedBlock.end + 1)
|
||||
];
|
||||
|
||||
await writeFile(gitignorePath, filtered.join('\n'));
|
||||
};
|
||||
|
||||
const setCollectionGitRemote = async (workspacePath, collectionPath, remoteUrl) => {
|
||||
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
|
||||
throw new Error('A non-empty Git remote URL is required');
|
||||
}
|
||||
const trimmedUrl = remoteUrl.trim();
|
||||
|
||||
return withLock(getWorkspaceLockKey(workspacePath), async () => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
const target = path.normalize(collectionPath);
|
||||
let matched = false;
|
||||
|
||||
config.collections = (config.collections || []).map((c) => {
|
||||
if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c;
|
||||
matched = true;
|
||||
return { ...c, remote: trimmedUrl };
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
throw new Error('Collection not found in workspace');
|
||||
}
|
||||
|
||||
await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config));
|
||||
await addCollectionToWorkspaceGitignore(workspacePath, collectionPath);
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
const clearCollectionGitRemote = async (workspacePath, collectionPath) => {
|
||||
return withLock(getWorkspaceLockKey(workspacePath), async () => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
const target = path.normalize(collectionPath);
|
||||
let matched = false;
|
||||
|
||||
config.collections = (config.collections || []).map((c) => {
|
||||
if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c;
|
||||
matched = true;
|
||||
const updated = { ...c };
|
||||
delete updated.remote;
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
throw new Error('Collection not found in workspace');
|
||||
}
|
||||
|
||||
await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config));
|
||||
await removeCollectionFromWorkspaceGitignore(workspacePath, collectionPath);
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => {
|
||||
return withLock(getWorkspaceLockKey(workspacePath), async () => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
@@ -432,36 +556,34 @@ const reorderWorkspaceCollections = async (workspacePath, collectionPaths) => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveAndFilterWorkspaceCollections = (workspacePath, rawCollections) => {
|
||||
const seenPaths = new Set();
|
||||
|
||||
return (rawCollections || [])
|
||||
.map((collection) => {
|
||||
if (!collection.path) return collection;
|
||||
const collectionPath = posixifyPath(collection.path);
|
||||
const absolute = path.isAbsolute(collectionPath)
|
||||
? collectionPath
|
||||
: path.resolve(workspacePath, collectionPath);
|
||||
return { ...collection, path: absolute };
|
||||
})
|
||||
.map((collection) => {
|
||||
if (!collection.path) return null;
|
||||
const normalizedPath = path.normalize(collection.path);
|
||||
if (seenPaths.has(normalizedPath)) return null;
|
||||
seenPaths.add(normalizedPath);
|
||||
|
||||
if (isValidCollectionDirectory(collection.path)) return collection;
|
||||
if (collection.remote) return { ...collection, notFoundLocally: true };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getWorkspaceCollections = (workspacePath) => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
const collections = config.collections || [];
|
||||
|
||||
const seenPaths = new Set();
|
||||
return collections
|
||||
.map((collection) => {
|
||||
const collectionPath = collection.path ? posixifyPath(collection.path) : collection.path;
|
||||
if (collectionPath && !path.isAbsolute(collectionPath)) {
|
||||
return {
|
||||
...collection,
|
||||
path: path.resolve(workspacePath, collectionPath)
|
||||
};
|
||||
}
|
||||
return { ...collection, path: collectionPath };
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!collection.path) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = path.normalize(collection.path);
|
||||
if (seenPaths.has(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
seenPaths.add(normalizedPath);
|
||||
if (!isValidCollectionDirectory(collection.path)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return resolveAndFilterWorkspaceCollections(workspacePath, config.collections);
|
||||
};
|
||||
|
||||
const getWorkspaceApiSpecs = (workspacePath) => {
|
||||
@@ -571,8 +693,11 @@ module.exports = {
|
||||
updateWorkspaceDocs,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
setCollectionGitRemote,
|
||||
clearCollectionGitRemote,
|
||||
reorderWorkspaceCollections,
|
||||
getWorkspaceCollections,
|
||||
resolveAndFilterWorkspaceCollections,
|
||||
getWorkspaceApiSpecs,
|
||||
addApiSpecToWorkspace,
|
||||
removeApiSpecFromWorkspace,
|
||||
|
||||
@@ -2,9 +2,14 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const yaml = require('js-yaml');
|
||||
const { reorderWorkspaceCollections } = require('../../src/utils/workspace-config');
|
||||
const {
|
||||
reorderWorkspaceCollections,
|
||||
setCollectionGitRemote,
|
||||
clearCollectionGitRemote,
|
||||
getWorkspaceCollections
|
||||
} = require('../../src/utils/workspace-config');
|
||||
|
||||
const collection = (name, pathSegment) => ({ name, path: pathSegment });
|
||||
const collection = (name, pathSegment, extra = {}) => ({ name, path: pathSegment, ...extra });
|
||||
|
||||
describe('reorderWorkspaceCollections', () => {
|
||||
let workspacePath;
|
||||
@@ -74,3 +79,192 @@ describe('reorderWorkspaceCollections', () => {
|
||||
expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git remote on workspace collections', () => {
|
||||
let workspacePath;
|
||||
|
||||
const writeYml = (collections) => {
|
||||
const lines = [
|
||||
'opencollection: 1.0.0',
|
||||
'info:',
|
||||
' name: Test',
|
||||
' type: workspace',
|
||||
'collections:'
|
||||
];
|
||||
for (const c of collections) {
|
||||
lines.push(` - name: "${c.name}"`);
|
||||
lines.push(` path: "${c.path}"`);
|
||||
if (c.remote) lines.push(` remote: "${c.remote}"`);
|
||||
}
|
||||
lines.push('specs: []');
|
||||
lines.push('docs: \'\'');
|
||||
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), lines.join('\n'));
|
||||
};
|
||||
|
||||
const readCollectionsFromYml = () => {
|
||||
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
||||
return (yaml.load(raw).collections || []);
|
||||
};
|
||||
|
||||
const absPath = (relativePath) => path.resolve(workspacePath, relativePath);
|
||||
|
||||
const ensureCollectionDir = (relativePath) => {
|
||||
const dir = path.join(workspacePath, relativePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-git-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote sets remote on the matching entry only', async () => {
|
||||
writeYml([
|
||||
collection('API', 'collections/api'),
|
||||
collection('Backend', 'collections/backend')
|
||||
]);
|
||||
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/backend'), 'https://github.com/x/backend');
|
||||
|
||||
const entries = readCollectionsFromYml();
|
||||
expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' });
|
||||
expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' });
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote rejects empty URL', async () => {
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
await expect(
|
||||
setCollectionGitRemote(workspacePath, absPath('collections/api'), ' ')
|
||||
).rejects.toThrow(/non-empty/i);
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote throws when collection is missing from workspace.yml', async () => {
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
await expect(
|
||||
setCollectionGitRemote(workspacePath, absPath('collections/missing'), 'https://github.com/x/y')
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
test('clearCollectionGitRemote removes only the remote field', async () => {
|
||||
writeYml([
|
||||
collection('API', 'collections/api', { remote: 'https://github.com/x/api' }),
|
||||
collection('Backend', 'collections/backend', { remote: 'https://github.com/x/backend' })
|
||||
]);
|
||||
|
||||
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
||||
|
||||
const entries = readCollectionsFromYml();
|
||||
expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' });
|
||||
expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' });
|
||||
});
|
||||
|
||||
test('getWorkspaceCollections keeps git-backed entries even when local folder is missing', () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([
|
||||
collection('API', 'collections/api'),
|
||||
collection('Missing', 'collections/missing', { remote: 'https://github.com/x/missing' })
|
||||
]);
|
||||
|
||||
const result = getWorkspaceCollections(workspacePath);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const api = result.find((r) => r.name === 'API');
|
||||
const missing = result.find((r) => r.name === 'Missing');
|
||||
expect(api.notFoundLocally).toBeUndefined();
|
||||
expect(missing.notFoundLocally).toBe(true);
|
||||
expect(missing.remote).toBe('https://github.com/x/missing');
|
||||
});
|
||||
|
||||
test('getWorkspaceCollections still drops missing entries that have no remote', () => {
|
||||
writeYml([collection('Missing', 'collections/missing')]);
|
||||
expect(getWorkspaceCollections(workspacePath)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote adds the collection path to .gitignore', async () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
||||
expect(gitignore.split('\n')).toContain('collections/api/');
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote does not duplicate the .gitignore entry on repeated calls', async () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api-renamed');
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
||||
const matches = gitignore.split('\n').filter((line) => line.trim() === 'collections/api/');
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote preserves existing .gitignore content', async () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
fs.writeFileSync(path.join(workspacePath, '.gitignore'), '# user notes\nnode_modules\n.env\n');
|
||||
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
||||
expect(gitignore).toContain('# user notes');
|
||||
expect(gitignore).toContain('node_modules');
|
||||
expect(gitignore).toContain('.env');
|
||||
expect(gitignore).toContain('collections/api/');
|
||||
});
|
||||
|
||||
test('clearCollectionGitRemote removes the collection path from .gitignore', async () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([collection('API', 'collections/api', { remote: 'https://github.com/x/api' })]);
|
||||
fs.writeFileSync(path.join(workspacePath, '.gitignore'), [
|
||||
'node_modules',
|
||||
'# Bruno managed collection remotes',
|
||||
'collections/api/',
|
||||
'# End Bruno managed collection remotes',
|
||||
''
|
||||
].join('\n'));
|
||||
|
||||
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
||||
expect(gitignore.split('\n')).not.toContain('collections/api/');
|
||||
expect(gitignore).toContain('node_modules');
|
||||
});
|
||||
|
||||
test('clearCollectionGitRemote preserves user-owned .gitignore entries', async () => {
|
||||
ensureCollectionDir('collections/api');
|
||||
writeYml([collection('API', 'collections/api')]);
|
||||
fs.writeFileSync(path.join(workspacePath, '.gitignore'), 'node_modules\ncollections/api/\n');
|
||||
|
||||
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
||||
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
||||
expect(gitignore.split('\n')).toContain('collections/api/');
|
||||
expect(gitignore).toContain('node_modules');
|
||||
});
|
||||
|
||||
test('setCollectionGitRemote skips .gitignore for collections outside the workspace', async () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-outside-'));
|
||||
fs.writeFileSync(path.join(outsideDir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' }));
|
||||
try {
|
||||
writeYml([collection('External', outsideDir)]);
|
||||
await setCollectionGitRemote(workspacePath, outsideDir, 'https://github.com/x/external');
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
||||
expect(gitignore).not.toContain(outsideDir);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
9
tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore
vendored
Normal file
9
tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Secrets
|
||||
.env*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "SampleColl",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
opencollection: 1.0.0
|
||||
info:
|
||||
name: "Fixture WS"
|
||||
type: workspace
|
||||
|
||||
collections:
|
||||
- name: "SampleColl"
|
||||
path: "collections/sample-coll"
|
||||
|
||||
specs:
|
||||
|
||||
docs: ''
|
||||
9
tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore
vendored
Normal file
9
tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Secrets
|
||||
.env*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,13 @@
|
||||
opencollection: 1.0.0
|
||||
info:
|
||||
name: "Ghost WS"
|
||||
type: workspace
|
||||
|
||||
collections:
|
||||
- name: "Missing Coll"
|
||||
path: "collections/missing-coll"
|
||||
remote: "https://github.com/usebruno/sample-collection.git"
|
||||
|
||||
specs:
|
||||
|
||||
docs: ''
|
||||
@@ -0,0 +1,291 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { test, expect, closeElectronApp } from '../../../playwright';
|
||||
import { switchWorkspace, createCollection } from '../../utils/page';
|
||||
|
||||
type CollectionEntry = { name?: string; path?: string; remote?: string };
|
||||
type WorkspaceConfig = { collections?: CollectionEntry[] };
|
||||
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
const fixturesPath = path.join(__dirname, 'fixtures');
|
||||
|
||||
const REMOTE_URL = 'https://github.com/usebruno/sample-collection.git';
|
||||
|
||||
const FIXTURE_WS_NAME = 'Fixture WS';
|
||||
const GHOST_WS_NAME = 'Ghost WS';
|
||||
const SAMPLE_COLL_GITIGNORE_LINE = 'collections/sample-coll/';
|
||||
|
||||
async function copyFixture(fixtureName: string, destDir: string): Promise<string> {
|
||||
const src = path.join(fixturesPath, fixtureName);
|
||||
await fs.promises.cp(src, destDir, { recursive: true });
|
||||
return destDir;
|
||||
}
|
||||
|
||||
function readWorkspaceYml(workspacePath: string): WorkspaceConfig {
|
||||
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
||||
return yaml.load(raw) as WorkspaceConfig;
|
||||
}
|
||||
|
||||
function readGitignoreLines(workspacePath: string): string[] {
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (!fs.existsSync(gitignorePath)) return [];
|
||||
return fs.readFileSync(gitignorePath, 'utf8').split('\n');
|
||||
}
|
||||
|
||||
test.describe('Git-backed collections', () => {
|
||||
test.describe('Workspace overview', () => {
|
||||
test('connect to Git updates workspace.yml, shows badge + remote URL, and adds .gitignore entry', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const workspacePath = await createTmpDir('git-ws-connect');
|
||||
await copyFixture('workspace-with-collection', workspacePath);
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await switchWorkspace(page, FIXTURE_WS_NAME);
|
||||
|
||||
await test.step('Navigate to workspace overview', async () => {
|
||||
await page.locator('.titlebar-left .home-button').click();
|
||||
});
|
||||
|
||||
const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' });
|
||||
|
||||
await test.step('Open Connect to Git modal from collection menu', async () => {
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await card.locator('.collection-menu').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click();
|
||||
});
|
||||
|
||||
await test.step('Submit the modal with a Git URL', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await modal.locator('#remoteUrl').fill(REMOTE_URL);
|
||||
await modal.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||
await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify Git badge and remote URL render on the card', async () => {
|
||||
await expect(card.locator('.collection-remote')).toContainText(REMOTE_URL, { timeout: 5000 });
|
||||
await expect(card.getByText('Git', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify workspace.yml records the remote on the matching entry', async () => {
|
||||
const config = readWorkspaceYml(workspacePath);
|
||||
const entry = config.collections?.find((c) => c.name === 'SampleColl');
|
||||
expect(entry?.remote).toBe(REMOTE_URL);
|
||||
});
|
||||
|
||||
await test.step('Verify .gitignore contains the collection path', async () => {
|
||||
expect(readGitignoreLines(workspacePath)).toContain(SAMPLE_COLL_GITIGNORE_LINE);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('remove Git remote clears the badge, the workspace.yml field, and the .gitignore line', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const workspacePath = await createTmpDir('git-ws-disconnect');
|
||||
await copyFixture('workspace-with-collection', workspacePath);
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await switchWorkspace(page, FIXTURE_WS_NAME);
|
||||
|
||||
const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' });
|
||||
|
||||
await test.step('Connect the collection to Git first', async () => {
|
||||
await page.locator('.titlebar-left .home-button').click();
|
||||
await card.locator('.collection-menu').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click();
|
||||
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' });
|
||||
await modal.locator('#remoteUrl').fill(REMOTE_URL);
|
||||
await modal.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||
await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Open Remove Git Remote modal', async () => {
|
||||
await card.locator('.collection-menu').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' }).click();
|
||||
|
||||
const removeModal = page.locator('.bruno-modal-card').filter({ hasText: 'Remove Git Remote' });
|
||||
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await removeModal.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await expect(page.getByText('Git remote removed')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify badge and remote URL line are gone from the card', async () => {
|
||||
await expect(card.locator('.collection-remote')).toHaveCount(0, { timeout: 5000 });
|
||||
await expect(card.getByText('Git', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Verify workspace.yml no longer carries the remote field', async () => {
|
||||
const config = readWorkspaceYml(workspacePath);
|
||||
const entry = config.collections?.find((c) => c.name === 'SampleColl');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.remote).toBeUndefined();
|
||||
});
|
||||
|
||||
await test.step('Verify .gitignore no longer contains the collection path', async () => {
|
||||
expect(readGitignoreLines(workspacePath)).not.toContain(SAMPLE_COLL_GITIGNORE_LINE);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('Connect to Git modal rejects empty and invalid URLs', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const workspacePath = await createTmpDir('git-ws-validation');
|
||||
await copyFixture('workspace-with-collection', workspacePath);
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await switchWorkspace(page, FIXTURE_WS_NAME);
|
||||
|
||||
await test.step('Open Connect to Git modal', async () => {
|
||||
await page.locator('.titlebar-left .home-button').click();
|
||||
const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' });
|
||||
await card.locator('.collection-menu').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click();
|
||||
});
|
||||
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' });
|
||||
|
||||
await test.step('Empty URL should show validation error and keep modal open', async () => {
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await modal.locator('#remoteUrl').fill('');
|
||||
await modal.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||
|
||||
await expect(modal.locator('.text-red-500').first()).toBeVisible({ timeout: 2000 });
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Malformed URL should also be rejected', async () => {
|
||||
await modal.locator('#remoteUrl').fill('not-a-url');
|
||||
await modal.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||
|
||||
await expect(modal.locator('.text-red-500').first()).toBeVisible({ timeout: 2000 });
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Valid URL submits successfully', async () => {
|
||||
await modal.locator('#remoteUrl').fill(REMOTE_URL);
|
||||
await modal.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||
await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 });
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('default workspace does not expose Git options on the collection card', async ({ launchElectronApp, createTmpDir }) => {
|
||||
// No fixture: the playwright fixture default-seeds preferences to skip onboarding,
|
||||
// and Bruno auto-creates the default workspace under the userData path.
|
||||
const collectionDir = await createTmpDir('git-default-coll');
|
||||
|
||||
const app = await launchElectronApp();
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Verify we are on the default workspace', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Create a collection in the default workspace', async () => {
|
||||
await createCollection(page, 'DefaultColl', collectionDir);
|
||||
});
|
||||
|
||||
await test.step('Open the collection menu in the workspace overview', async () => {
|
||||
await page.locator('.titlebar-left .home-button').click();
|
||||
const card = page.locator('.collection-card').filter({ hasText: 'DefaultColl' });
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await card.locator('.collection-menu').click();
|
||||
});
|
||||
|
||||
await test.step('No Git-related menu items should be visible', async () => {
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' })).toHaveCount(0);
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Copy Git URL' })).toHaveCount(0);
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sidebar ghost row', () => {
|
||||
test('git-backed entry whose folder is missing renders as a clickable ghost row', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const workspacePath = await createTmpDir('git-ws-ghost');
|
||||
await copyFixture('workspace-with-ghost', workspacePath);
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await switchWorkspace(page, GHOST_WS_NAME);
|
||||
|
||||
const ghostRow = page.getByTestId('sidebar-git-collection-row').filter({ hasText: 'Missing Coll' });
|
||||
|
||||
await test.step('Ghost row appears in the sidebar', async () => {
|
||||
await expect(ghostRow).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Ghost row is not also rendered as a normal sidebar collection row', async () => {
|
||||
await expect(
|
||||
page.getByTestId('sidebar-collection-row').filter({ hasText: 'Missing Coll' })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Clicking the ghost row opens the Clone Git Repository modal pre-filled with the remote URL', async () => {
|
||||
await ghostRow.click();
|
||||
const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: 'Clone' });
|
||||
await cloneModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(cloneModal).toContainText(REMOTE_URL);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('right-clicking a ghost row exposes Remove Git Remote, which prunes both the entry and the row', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const workspacePath = await createTmpDir('git-ws-ghost-remove');
|
||||
await copyFixture('workspace-with-ghost', workspacePath);
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await switchWorkspace(page, GHOST_WS_NAME);
|
||||
|
||||
const ghostRow = page.getByTestId('sidebar-git-collection-row').filter({ hasText: 'Missing Coll' });
|
||||
|
||||
await test.step('Wait for the ghost row to appear', async () => {
|
||||
await expect(ghostRow).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Right-click the ghost row and choose Remove Git Remote', async () => {
|
||||
await ghostRow.click({ button: 'right' });
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' }).click();
|
||||
|
||||
const removeModal = page.locator('.bruno-modal-card').filter({ hasText: 'Remove Git Remote' });
|
||||
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await removeModal.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await expect(page.getByText('Git remote removed')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Ghost row disappears once the remote field is removed', async () => {
|
||||
await expect(ghostRow).toHaveCount(0, { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('workspace.yml entry persists but no longer has the remote field', async () => {
|
||||
const config = readWorkspaceYml(workspacePath);
|
||||
const entry = config.collections?.find((c) => c.name === 'Missing Coll');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.remote).toBeUndefined();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"lastOpenedWorkspaces": ["{{workspacePath}}"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user