diff --git a/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js new file mode 100644 index 000000000..921ce1591 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js new file mode 100644 index 000000000..dcba9f253 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js @@ -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 ( + + {showCloneModal && ( + + )} + {showRemoveGitModal && ( + setShowRemoveGitModal(false)} + /> + )} +
+
+ +
{entry.name}
+
+
+
e.stopPropagation()}> + + + + + +
+
+
+
+ ); +}; + +export default GitRemoteCollectionRow; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index f0ec52c6c..0522c2327 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -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 ) or, for non-default workspaces, + // a "ghost" git-backed entry whose local folder is missing (rendered via + // 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 ( {isCreatingCollection && ( @@ -55,13 +72,12 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis onOpenAdvanced={onOpenAdvancedCreate} /> )} - {workspaceCollections && workspaceCollections.length - ? workspaceCollections.map((c) => { - return ( - - ); - }) - : null} + {sidebarEntries.map((entry) => { + if (entry.kind === 'loaded') { + return ; + } + return ; + })} ); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js new file mode 100644 index 000000000..4d0d38f94 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js @@ -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 ( + formik.handleSubmit()} handleCancel={onClose}> +
e.preventDefault()}> + {collectionName ? ( +
+ Linking {collectionName} to a remote Git repository. + The URL is stored in workspace.yml; local files are not changed. +
+ ) : null} +
+ + + {formik.touched.remoteUrl && formik.errors.remoteUrl ? ( +
{formik.errors.remoteUrl}
+ ) : null} +
+
+
+ ); +}; + +export default ConnectGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js new file mode 100644 index 000000000..1a0fb1ff1 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js @@ -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 ( + +
+

+ Disconnect {collectionName} from its Git remote? +

+ {remoteUrl ? ( +

{remoteUrl}

+ ) : null} +

+ This only removes the remote URL from workspace.yml. Local files + and any .git folder are left untouched. +

+
+
+ ); +}; + +export default RemoveGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js index ba55f5a77..3211de1a2 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js @@ -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}; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js index 505bffb73..51253c498 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -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 ( {renameCollectionModalOpen && selectedCollectionUid && ( @@ -205,6 +265,24 @@ const CollectionsList = ({ workspace }) => { /> )} + {showConnectGitModal && gitTarget && ( + + )} + + {showRemoveGitModal && gitTarget && ( + + )} +
{workspaceCollections.length === 0 ? (
@@ -225,8 +303,26 @@ const CollectionsList = ({ workspace }) => {
{collection.name}
+ {!isDefaultWorkspace && collection.isGitBacked && ( + } + > + Git + + )} + {!isDefaultWorkspace && collection.isLoaded === false && ( + Not cloned + )}
{collection.pathname}
+ {!isDefaultWorkspace && collection.isGitBacked && collection.gitRemoteUrl && ( +
+ + {collection.gitRemoteUrl} +
+ )}
{ {getRevealInFolderLabel()}
+ {!isDefaultWorkspace && ( + <> + {collection.isGitBacked && ( +
{ + e.stopPropagation(); + handleCopyGitUrl(collection); + }} + > + + Copy Git URL +
+ )} + {!collection.isGitBacked && collection.isLoaded !== false && ( +
{ + e.stopPropagation(); + handleConnectGit(collection); + }} + > + + Connect to Git +
+ )} + {collection.isGitBacked && ( +
{ + e.stopPropagation(); + handleRemoveGit(collection); + }} + > + + Remove Git Remote +
+ )} + + )}
{ diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index e9ab09820..88ea6f28a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -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))); diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 1b5a2551c..222b2611a 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -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(); diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index b02c49777..1a29e0d60 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -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, diff --git a/packages/bruno-electron/tests/utils/workspace-config.spec.js b/packages/bruno-electron/tests/utils/workspace-config.spec.js index e70637214..a7a8951b9 100644 --- a/packages/bruno-electron/tests/utils/workspace-config.spec.js +++ b/packages/bruno-electron/tests/utils/workspace-config.spec.js @@ -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 }); + } + }); +}); diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore new file mode 100644 index 000000000..901075425 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json new file mode 100644 index 000000000..b2d19af48 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "SampleColl", + "type": "collection" +} diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml new file mode 100644 index 000000000..5c5e29791 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml @@ -0,0 +1,12 @@ +opencollection: 1.0.0 +info: + name: "Fixture WS" + type: workspace + +collections: + - name: "SampleColl" + path: "collections/sample-coll" + +specs: + +docs: '' diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore new file mode 100644 index 000000000..901075425 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml new file mode 100644 index 000000000..b5da4c289 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml @@ -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: '' diff --git a/tests/workspace/git-backed-collections/git-backed-collections.spec.ts b/tests/workspace/git-backed-collections/git-backed-collections.spec.ts new file mode 100644 index 000000000..93b269138 --- /dev/null +++ b/tests/workspace/git-backed-collections/git-backed-collections.spec.ts @@ -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 { + 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); + }); + }); +}); diff --git a/tests/workspace/git-backed-collections/init-user-data/preferences.json b/tests/workspace/git-backed-collections/init-user-data/preferences.json new file mode 100644 index 000000000..630713e03 --- /dev/null +++ b/tests/workspace/git-backed-collections/init-user-data/preferences.json @@ -0,0 +1,11 @@ +{ + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + }, + "workspaces": { + "lastOpenedWorkspaces": ["{{workspacePath}}"] + } +}