feat: integrate Git remote for collections (#7879)

This commit is contained in:
naman-bruno
2026-05-04 17:04:47 +05:30
committed by GitHub
parent 118ba801aa
commit 47a1186c4a
18 changed files with 1269 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
# Secrets
.env*
# Dependencies
node_modules
# OS files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "SampleColl",
"type": "collection"
}

View File

@@ -0,0 +1,12 @@
opencollection: 1.0.0
info:
name: "Fixture WS"
type: workspace
collections:
- name: "SampleColl"
path: "collections/sample-coll"
specs:
docs: ''

View File

@@ -0,0 +1,9 @@
# Secrets
.env*
# Dependencies
node_modules
# OS files
.DS_Store
Thumbs.db

View File

@@ -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: ''

View File

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

View File

@@ -0,0 +1,11 @@
{
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
},
"workspaces": {
"lastOpenedWorkspaces": ["{{workspacePath}}"]
}
}