diff --git a/.gitignore b/.gitignore index 808df3dbf..66cf19215 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ bruno.iml .idea .vscode .cursor +.claude # Playwright /blob-report/ diff --git a/package-lock.json b/package-lock.json index 1b0e45374..f09839eea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "packages/bruno-filestore" ], "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "git-url-parse": "^14.1.0" }, "devDependencies": { "@eslint/compat": "^1.3.2", @@ -5521,6 +5522,44 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@lydell/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", @@ -17363,6 +17402,25 @@ "node": ">=6.0" } }, + "node_modules/git-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz", + "integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==", + "license": "MIT", + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^8.1.0" + } + }, + "node_modules/git-url-parse": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-14.1.0.tgz", + "integrity": "sha512-8xg65dTxGHST3+zGpycMMFZcoTzAdZ2dOtu4vmgIfkTFnVHBxHMzBC2L1k8To7EmrSiHesT8JgPLT91VKw1B5g==", + "license": "MIT", + "dependencies": { + "git-up": "^7.0.0" + } + }, "node_modules/github-markdown-css": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", @@ -18903,6 +18961,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-ssh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", + "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -22427,6 +22494,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-path": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz", + "integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz", + "integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==", + "license": "MIT", + "dependencies": { + "parse-path": "^7.0.0" + } + }, "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -23990,6 +24075,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocols": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", + "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -26752,6 +26843,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/simple-git": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz", + "integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -33329,6 +33458,7 @@ "mime-types": "^2.1.35", "nanoid": "3.3.8", "qs": "^6.14.1", + "simple-git": "^3.22.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^9.0.0", diff --git a/package.json b/package.json index 4531754ad..2b92e7ed6 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ } }, "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "git-url-parse": "^14.1.0" } } \ No newline at end of file diff --git a/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js new file mode 100644 index 000000000..88fe7f30d --- /dev/null +++ b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.colors.danger}; + pre { + color: ${(props) => props.theme.colors.danger}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js b/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js new file mode 100644 index 000000000..31a8827b2 --- /dev/null +++ b/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js @@ -0,0 +1,34 @@ +import React from 'react'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; +import { useState } from 'react'; +import StyledWrapper from './StyledWrapper'; + +const IpcErrorModal = ({ error }) => { + const [showModal, setShowModal] = useState(true); + return ( + <> + {showModal ? ( + + + { + setShowModal(false); + }} + disableCloseOnOutsideClick={true} + disableEscapeKey={true} + > +
{error}
+
+
+
+ ) : null} + + ); +}; + +export default IpcErrorModal; diff --git a/packages/bruno-app/src/components/Git/GitNotFoundModal/index.js b/packages/bruno-app/src/components/Git/GitNotFoundModal/index.js new file mode 100644 index 000000000..c3e8b2bc4 --- /dev/null +++ b/packages/bruno-app/src/components/Git/GitNotFoundModal/index.js @@ -0,0 +1,62 @@ +import React from 'react'; +import Modal from 'components/Modal/index'; +import Portal from 'components/Portal/index'; + +const getOSName = () => { + const platform = window.navigator.userAgentData?.platform || ''; + if (platform.startsWith('Win')) { + return 'Windows'; + } else if (platform.startsWith('Mac')) { + return 'macOS'; + } else if (platform.startsWith('Linux')) { + return 'Linux'; + } else { + return 'your OS'; + } +}; + +const getDownloadUrl = (os) => { + switch (os) { + case 'Windows': + return 'https://git-scm.com/download/win'; + case 'macOS': + return 'https://git-scm.com/download/mac'; + case 'Linux': + return 'https://git-scm.com/download/linux'; + default: + return 'https://git-scm.com/download'; + } +}; + +const GitNotFoundModal = ({ onClose }) => { + const osName = getOSName(); + const downloadUrl = getDownloadUrl(osName); + + return ( + + +
+

Git was not detected on your system. You need to install Git to proceed.

+

+ You can download Git for {osName} here: +

+

+ window.open(downloadUrl, '_blank')} + > + Download Git for {osName} + +

+
+
+
+ ); +}; + +export default GitNotFoundModal; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js new file mode 100644 index 000000000..11a870abf --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js @@ -0,0 +1,28 @@ +import styled from 'styled-components'; +import { darken } from 'polished'; + +const StyledWrapper = styled.div` + .current-group { + background-color: ${(props) => props.theme.background.surface1}; + border-radius: 4px; + padding: 0.4rem; + cursor: pointer; + border: 1px solid ${(props) => props.theme.background.surface2}; + } + + .current-group:hover { + background-color: ${(props) => darken(0.03, props.theme.background.surface1)}; + border-color: ${(props) => darken(0.03, props.theme.background.surface2)}; + } + + /* Fix dropdown positioning */ + [data-tippy-root] { + left: 0 !important; + } + + .bruno-modal-footer { + padding-top: 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js new file mode 100644 index 000000000..e02783d18 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js @@ -0,0 +1,887 @@ +import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions'; +import Modal from 'components/Modal'; +import { isElectron } from 'utils/common/platform'; +import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons'; +import InfoTip from 'components/InfoTip/index'; +import Help from 'components/Help'; +import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import Dropdown from 'components/Dropdown'; +import { postmanToBruno } from 'utils/importers/postman-collection'; +import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection'; +import { convertOpenapiToBruno } from 'utils/importers/openapi-collection'; +import { processBrunoCollection } from 'utils/importers/bruno-collection'; +import { wsdlToBruno } from '@usebruno/converters'; +import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; +import get from 'lodash/get'; + +const STATUS = { + LOADING: 'loading', + SUCCESS: 'success', + ERROR: 'error' +}; + +const IMPORT_TYPE = { + BULK: 'bulk', + MULTIPLE: 'multiple' +}; + +const groupingOptions = [ + { value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' }, + { value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' } +]; + +// Extract collection name from raw data +const getCollectionName = (format, rawData) => { + if (!rawData) return 'Collection'; + + switch (format) { + case 'openapi': + return rawData.info?.title || 'OpenAPI Collection'; + case 'postman': + return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection'; + case 'insomnia': + // For Insomnia v4 format, name is in the workspace resource + if (rawData.resources && Array.isArray(rawData.resources)) { + const workspace = rawData.resources.find((r) => r._type === 'workspace'); + if (workspace?.name) { + return workspace.name; + } + } + // Fallback to root name property + return rawData.name || 'Insomnia Collection'; + case 'bruno': + return rawData.name || 'Bruno Collection'; + case 'wsdl': + return 'WSDL Collection'; + default: + return 'Collection'; + } +}; + +// Convert raw data to Bruno collection format +const convertCollection = async (format, rawData, groupingType) => { + let collection; + + switch (format) { + case 'openapi': + collection = convertOpenapiToBruno(rawData, { groupBy: groupingType }); + break; + case 'wsdl': + collection = await wsdlToBruno(rawData); + break; + case 'postman': + collection = await postmanToBruno(rawData); + break; + case 'insomnia': + collection = convertInsomniaToBruno(rawData); + break; + case 'bruno': + collection = await processBrunoCollection(rawData); + break; + default: + throw new Error('Unknown collection format'); + } + + return collection; +}; + +export function normalizeName(name) { + if (typeof name !== 'string') { + return ''; + } + return name.trim().toLowerCase(); +} + +/** + * Generate a unique name by adding "copy" suffix if the name already exists. + * @param {string} baseName - The original name + * @param {function} checkExists - Function that returns true if name exists + * @returns {string} - Unique name with "copy" suffix if needed + */ +export function generateUniqueName(baseName, checkExists) { + const normalizedBase = normalizeName(baseName); + if (!checkExists(normalizedBase)) { + return baseName; + } + + let counter = 1; + let uniqueName = `${baseName} copy`; + + while (checkExists(normalizeName(uniqueName))) { + counter++; + uniqueName = `${baseName} copy ${counter}`; + } + + return uniqueName; +} + +export const BulkImportCollectionLocation = ({ + onClose, + handleSubmit, + importData +}) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const preferences = useSelector((state) => state.app.preferences); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default'; + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultCollectionLocation', '') + : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); + + const [status, setStatus] = useState({}); + const [errorMessages, setErrorMessages] = useState({}); + const [importStarted, setImportStarted] = useState(false); + const [environmentStatus, setEnvironmentStatus] = useState({}); + const [showErrorModal, setShowErrorModal] = useState(false); + const [selectedError, setSelectedError] = useState(null); + const [applyToGlobal, setApplyToGlobal] = useState(true); + const [applyToCollection, setApplyToCollection] = useState(false); + const [groupingType, setGroupingType] = useState('tags'); + const [collectionFormat, setCollectionFormat] = useState('bru'); + const [renamedCollectionNames, setRenamedCollectionNames] = useState({}); + const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({}); + + // Extract data based on import type + const importType = importData?.type; + const isBulkImport = importType === IMPORT_TYPE.BULK; + const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE; + + // For bulk import (ZIP files) + const importedCollectionFromBulk = isBulkImport ? importData.collection : []; + const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : []; + + // For multiple files import + const filesData = isMultipleImport ? importData.filesData : []; + const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi'); + + // Create unified collection structure for display + const importedCollection = isMultipleImport + ? filesData.map((fileData, index) => ({ + uid: `file-${index}`, + name: getCollectionName(fileData.type, fileData.data), + _fileData: fileData + })) + : importedCollectionFromBulk; + + const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : []; + + const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments); + const existingCollections = useSelector((state) => state?.collections?.collections || []); + + // Initialize selected items based on import type + const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid)); + const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []); + + const allCollectionsSelected = selectedCollections.length === importedCollection.length; + const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length; + + // Sort collections to show selected items first, then unselected items + // This helps users see their selections at the top of the list + const sortedCollections = useMemo(() => { + const arr = [...importedCollection]; + arr.sort((a, b) => { + const aSelected = selectedCollections.includes(a.uid); + const bSelected = selectedCollections.includes(b.uid); + // Convert boolean to number: true = 1, false = 0 + // bSelected - aSelected means: selected items (1) come before unselected (0) + return Number(bSelected) - Number(aSelected); + }); + return arr; + }, [importedCollection, selectedCollections]); + + // Sort environments to show selected items first, then unselected items + // This helps users see their selections at the top of the list + const sortedEnvironments = useMemo(() => { + const arr = [...importedEnvironment]; + arr.sort((a, b) => { + const aSelected = selectedEnvironments.includes(a.uid); + const bSelected = selectedEnvironments.includes(b.uid); + // selected (true) should come before unselected (false) + return Number(bSelected) - Number(aSelected); + }); + return arr; + }, [importedEnvironment, selectedEnvironments]); + + const importStatus = useMemo(() => { + const selectedSet = new Set(selectedCollections); + const totalSelected = selectedCollections.length; + const failedCount = Object.entries(status).reduce((acc, [uid, s]) => { + return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc; + }, 0); + + return { + totalSelected, + failedCount + }; + }, [status, selectedCollections]); + + // Handlers + const handleCollectionToggle = (uid) => { + setSelectedCollections((prev) => + prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid] + ); + }; + const handleEnvironmentToggle = (uid) => { + setSelectedEnvironments((prev) => + prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid] + ); + }; + const handleSelectAllCollections = (e) => { + setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []); + }; + const handleSelectAllEnvironments = (e) => { + setSelectedEnvironments( + e.target.checked ? importedEnvironment.map((env) => env.uid) : [] + ); + }; + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + }; + + const GroupingDropdownIcon = forwardRef((props, ref) => { + const selectedOption = groupingOptions.find((option) => option.value === groupingType); + return ( +
+
+
{selectedOption.label}
+
+ +
+ ); + }); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + collectionLocation: defaultLocation + }, + validationSchema: Yup.object({ + collectionLocation: Yup.string() + .min(1, 'must be at least 1 character') + .max(500, 'must be 500 characters or less') + .required('Location is required') + }), + onSubmit: async (values) => { + let filteredCollections = []; + const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid)); + + if (isMultipleImport) { + // Convert selected files to collections at submit time + for (const item of selectedItems) { + try { + const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType); + if (collection) { + // Preserve the synthetic UID so status tracking, rename tracking, + // and UI rendering all use the same key + collection.uid = item.uid; + filteredCollections.push(collection); + } + } catch (err) { + console.warn(`Failed to convert file ${item._fileData.file.name}:`, err); + } + } + } else if (isBulkImport) { + // For bulk import, use selected collections directly + filteredCollections = selectedItems; + } + + const initialStatus = {}; + filteredCollections.forEach((col) => { + initialStatus[col.uid] = STATUS.LOADING; + }); + + setStatus(initialStatus); + setErrorMessages({}); + + const filteredEnvironments = importedEnvironment.filter((env) => + selectedEnvironments.includes(env.uid) + ); + + // Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix + const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name))); + const usedNames = new Set(); + const renamedNames = {}; + + filteredCollections.forEach((collection) => { + const originalName = collection.name; + let finalName = originalName; + let index = 0; + + while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) { + finalName = `${originalName} ${index + 1}`; + index++; + } + + collection.name = finalName; + usedNames.add(normalizeName(finalName)); + // Store renamed name for summary display + if (finalName !== originalName) { + renamedNames[collection.uid] = finalName; + } + }); + + setRenamedCollectionNames(renamedNames); + + // Process all selected environments and rename duplicates + // Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead + const collectionRenamedEnvNames = {}; + const globalRenamedEnvNames = {}; + + if (applyToCollection) { + // add selected environments to each selected collection + // Rename duplicates with "copy" suffix instead of filtering them out + filteredCollections.forEach((collection) => { + const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name))); + const usedNamesInBatch = new Set(); + + const envsForCollection = filteredEnvironments.map((env) => { + const originalName = env.name; + const normalizedOriginalName = normalizeName(originalName); + + // Check if name exists in collection or was already used in this batch + const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name); + const finalName = generateUniqueName(originalName, checkExists); + + // Track renamed name for summary display + if (finalName !== originalName) { + collectionRenamedEnvNames[env.uid] = finalName; + } + + usedNamesInBatch.add(normalizeName(finalName)); + existingNamesSet.add(normalizeName(finalName)); + return { ...env, name: finalName }; + }); + + collection.environments = envsForCollection; + }); + + // Mark all collection environments as success (they're processed with the collection import) + const envStatusUpdate = {}; + filteredEnvironments.forEach((env) => { + envStatusUpdate[env.uid] = STATUS.SUCCESS; + }); + setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate })); + + if (Object.keys(collectionRenamedEnvNames).length > 0) { + setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames })); + } + } + + if (applyToGlobal && filteredEnvironments.length > 0) { + // Pre-compute unique names for all environments to avoid race conditions + const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name))); + const usedNamesInBatch = new Set(); + const envsToImport = []; + + filteredEnvironments.forEach((environment) => { + const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name); + const uniqueName = generateUniqueName(environment.name, checkExists); + + if (uniqueName !== environment.name) { + globalRenamedEnvNames[environment.uid] = uniqueName; + } + usedNamesInBatch.add(normalizeName(uniqueName)); + envsToImport.push({ ...environment, name: uniqueName }); + }); + + if (Object.keys(globalRenamedEnvNames).length > 0) { + setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames })); + } + + envsToImport.forEach((envToImport) => { + const originalUid = envToImport.uid; + setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING })); + + dispatch(addGlobalEnvironment(envToImport)) + .then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS }))) + .catch((error) => { + setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR })); + setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' })); + }); + }); + } + + setImportStarted(true); + + if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) { + dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat })) + .catch((err) => { + console.error('Failed to import collections', err); + filteredCollections.forEach((collection) => { + setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR })); + setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' })); + }); + }); + } else { + handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat }); + } + } + }); + + const browse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string' && dirPath.length > 0) { + formik.setFieldValue('collectionLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('collectionLocation', ''); + console.error(error); + }); + }; + + useEffect(() => { + if (!isElectron()) { + return () => {}; + } + + const { ipcRenderer } = window; + + const handleImportStatus = (collectionId, status, errorMessage = '') => { + setStatus((prev) => ({ ...prev, [collectionId]: status })); + if (status === STATUS.ERROR) { + setErrorMessages((prev) => ({ + ...prev, + [collectionId]: errorMessage + })); + } + }; + + const importingCollectionStarted = ipcRenderer.on( + 'main:collection-import-started', + (collectionId) => { + handleImportStatus(collectionId, STATUS.LOADING); + } + ); + const importingCollectionCompleted = ipcRenderer.on( + 'main:collection-import-ended', + (collectionId) => { + handleImportStatus(collectionId, STATUS.SUCCESS); + } + ); + const importingCollectionFailed = ipcRenderer.on( + 'main:collection-import-failed', + (collectionId, { message }) => { + handleImportStatus(collectionId, STATUS.ERROR, message); + } + ); + const allCollectionsImportCompleted = ipcRenderer.on( + 'main:all-collections-import-ended', + (report) => { + toast.success(report?.message); + } + ); + return () => { + importingCollectionStarted(); + importingCollectionCompleted(); + importingCollectionFailed(); + allCollectionsImportCompleted(); + }; + }, []); + + const onSubmit = () => { + if (importStarted) { + onClose(); + } else { + formik.handleSubmit(); + } + }; + + const handleErrorClick = (error, uid) => { + setSelectedError({ message: error, uid }); + setShowErrorModal(true); + }; + + const ErrorModal = ({ error, onClose }) => ( + +
+
{error}
+
+
+ ); + + return ( + + +
e.preventDefault()}> +
+ {importStarted ? ( + <> +
+
+
Location
+
+ {formik.values.collectionLocation + || 'No location selected'} +
+
+ +
+
+ Importing Collections ({importStatus.totalSelected}) +
+ {importStatus.failedCount > 0 && importStatus.totalSelected > 0 && ( +
+ ({importStatus.failedCount}/{importStatus.totalSelected} failed) +
+ )} +
+
+ {sortedCollections + .filter((collection) => + selectedCollections.includes(collection.uid) + ) + .map((collection) => ( +
+
+
+ {status[collection.uid] === STATUS.LOADING && ( + + )} + {status[collection.uid] === STATUS.SUCCESS && ( +
+ +
+ )} + {status[collection.uid] === STATUS.ERROR && ( +
+ +
+ )} +
+ {renamedCollectionNames[collection.uid] || collection.name} +
+ {status[collection.uid] === STATUS.ERROR && ( + + )} +
+ ))} +
+
+ + {selectedEnvironments.length > 0 && ( +
+
+ Importing Environments ({selectedEnvironments.length}) +
+
+ {sortedEnvironments + .filter((env) => selectedEnvironments.includes(env.uid)) + .map((env) => ( +
+
+
+ {!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? ( + + ) : environmentStatus[env.uid] === STATUS.SUCCESS ? ( +
+ +
+ ) : environmentStatus[env.uid] === STATUS.ERROR ? ( +
+ +
+ ) : null} +
+ {renamedEnvironmentNames[env.uid] || env.name} +
+ {environmentStatus[env.uid] === STATUS.ERROR && ( + + )} +
+ ))} +
+
+ )} + + ) : ( + <> +
+
+ Collections ({importedCollection.length}) + +
+
+ {importedCollection.length === 0 && ( +
+ No collections found +
+ )} + {sortedCollections.map((collection) => ( + + ))} +
+
+ + {importType === 'bulk' && ( + <> +
+
+ Environments ({importedEnvironment.length}) + +
+
+ {importedEnvironment.length === 0 && ( +
+ No environments found +
+ )} + {sortedEnvironments.map((env) => ( + + ))} +
+
+ +
+
+ Environment Assignment +
+
+ + +
+
+ + )} + +
+
Location
+ { + formik.setFieldValue('collectionLocation', e.target.value); + }} + /> + {formik.touched.collectionLocation && formik.errors.collectionLocation ? ( +
+ {formik.errors.collectionLocation} +
+ ) : null} +
+ + Browse + +
+
+ +
+ + +
+ + {isMultipleImport && hasOpenApiSpec && ( +
+
+
+ +

+ Select whether to create folders according to the spec's paths or tags. +

+
+
+ } placement="bottom-start"> + {groupingOptions.map((option) => ( +
{ + dropdownTippyRef?.current?.hide(); + setGroupingType(option.value); + }} + > + {option.label} +
+ ))} +
+
+
+
+ )} + + )} +
+
+
+ + {showErrorModal && ( + setShowErrorModal(false)} + /> + )} +
+ ); +}; + +export default BulkImportCollectionLocation; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js new file mode 100644 index 000000000..a8681f35f --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js @@ -0,0 +1,30 @@ +import { normalizeName, generateUniqueName } from './index'; + +describe('BulkImportCollectionLocation helpers', () => { + describe('normalizeName', () => { + it('should trim and lowercase names', () => { + expect(normalizeName(' Beta ')).toBe('beta'); + expect(normalizeName('TEST')).toBe('test'); + expect(normalizeName(null)).toBe(''); + }); + }); + + describe('generateUniqueName', () => { + it('should return original name if no conflict', () => { + const checkExists = () => false; + expect(generateUniqueName('Beta', checkExists)).toBe('Beta'); + }); + + it('should add "copy" suffix on first conflict', () => { + const existing = new Set(['beta']); + const checkExists = (name) => existing.has(name); + expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy'); + }); + + it('should increment copy number on multiple conflicts', () => { + const existing = new Set(['beta', 'beta copy']); + const checkExists = (name) => existing.has(name); + expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js new file mode 100644 index 000000000..9a589967d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .info-box { + background-color: ${(props) => props.theme.background.mantle}; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.border.border2}; + padding: 10px; + border-radius: 5px; + margin-top: 5px; + width: 400px; + white-space: pre-wrap; + max-height: 150px; + overflow-y: auto; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js new file mode 100644 index 000000000..0c0ece623 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js @@ -0,0 +1,372 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { + browseDirectory, + cloneGitRepository, + openMultipleCollections, + scanForBrunoFiles +} from 'providers/ReduxStore/slices/collections/actions'; +import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app'; +import Modal from 'components/Modal'; +import * as path from 'path'; +import Portal from 'components/Portal'; +import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons'; +import { uuid } from 'utils/common/index'; +import StyledWrapper from './StyledWrapper'; +import { getRepoNameFromUrl } from 'utils/git'; +import GitNotFoundModal from 'components/Git/GitNotFoundModal/index'; +import get from 'lodash/get'; + +const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => { + const [collectionPaths, setCollectionPaths] = useState([]); + const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]); + const [processUid, setProcessUid] = useState(uuid()); + const [steps, setSteps] = useState([]); + const [view, setView] = useState('form'); + + const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]); + const { gitVersion } = useSelector((state) => state.app); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const preferences = useSelector((state) => state.app.preferences); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default'; + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultCollectionLocation', '') + : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); + const inputRef = useRef(); + const dispatch = useDispatch(); + + useEffect(() => { + if (progressData) { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' && !step?.completed + ? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData } + : step + ) + ); + } + }, [progressData]); + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.focus(); + } + }, []); + + const cloneInProgress = () => { + setSteps((prev) => [ + ...prev, + { + step: 'clone', + title: 'Cloning repository', + completed: false + } + ]); + }; + + const cloneFinished = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' + ? { ...step, title: 'Cloning successful', completed: true, info: '' } + : step + ) + ); + }; + + const cloneError = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' + ? { ...step, title: 'Cloning failed', completed: true, error: true } + : step + ) + ); + }; + + const scanInProgress = () => { + setSteps((prev) => [ + ...prev, + { + step: 'scan', + title: 'Scanning for Bruno files', + completed: false + } + ]); + }; + + const scanFinished = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step + ) + ); + }; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + repositoryUrl: collectionRepositoryUrl || '', + collectionLocation: defaultLocation + }, + validationSchema: Yup.object({ + repositoryUrl: Yup.string().required('Repository URL is required'), + collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required') + }), + onSubmit: async (values) => { + try { + setView('progress'); + cloneInProgress(); + const { repositoryUrl, collectionLocation } = values; + + const repoName = getRepoNameFromUrl(repositoryUrl); + const targetPath = path.join(collectionLocation, repoName); + + await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid })); + + cloneFinished(); + dispatch(removeGitOperationProgress(processUid)); + + scanInProgress(); + const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath)); + + scanFinished(); + setCollectionPaths(foundCollectionPaths); + } catch (err) { + cloneError(); + dispatch(removeGitOperationProgress(processUid)); + console.error(err); + } + } + }); + + const browse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + formik.setFieldValue('collectionLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('collectionLocation', ''); + console.error(error); + }); + }; + + const handleCollectionSelect = (collection) => { + setSelectedCollectionPaths((prevSelected) => + prevSelected.includes(collection) + ? prevSelected.filter((c) => c !== collection) + : [...prevSelected, collection] + ); + }; + + const getRelativePath = (fullPath, pathname) => { + let relativePath = path.relative(fullPath, pathname); + const { dir, name } = path.parse(relativePath); + return path.join(dir, name); + }; + + const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed); + + const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0; + + const isFooterHidden = () => steps.some((step) => !step.completed); + + const isError = () => steps.some((step) => step.error); + + const handleConfirm = () => { + const buttonText = getConfirmText(); + switch (buttonText) { + case 'Clone': + formik.handleSubmit(); + break; + case 'Close': + onClose(); + break; + case 'Open': + if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) { + dispatch(openMultipleCollections(selectedCollectionPaths)); + onClose(); + onFinish(); + } + break; + default: + break; + } + }; + + const getConfirmText = () => + !steps.length + ? 'Clone' + : steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length)) + ? 'Close' + : 'Open'; + + const handleBackButtonClick = () => { + setView('form'); + setSteps([]); + setSelectedCollectionPaths([]); + }; + + if (!gitVersion) { + return ; + } + + return ( + + + + {view === 'form' && ( +
e.preventDefault()}> +
+ {collectionRepositoryUrl + ? ( +
+
+ +
+
+
{getRepoNameFromUrl(collectionRepositoryUrl)}
+
+ {collectionRepositoryUrl} +
+
+
+ ) + : ( + <> + + + + )} + {formik.touched.repositoryUrl && formik.errors.repositoryUrl && ( +
{formik.errors.repositoryUrl}
+ )} + + + {formik.touched.collectionLocation && formik.errors.collectionLocation && ( +
{formik.errors.collectionLocation}
+ )} +
+ + Browse + +
+
+
+ )} + {view === 'progress' && ( + <> + {steps.length > 0 && ( +
+
    + {steps.map((step, index) => ( +
  • +
    + {step.error ? ( + + ) : ( + <> + {step.completed ? ( + + ) : ( + + )} + + )} + {step.title} +
    + {step.info && ( +
    +
    {step.info}
    +
    + )} +
  • + ))} +
+
+ )} + {isScanCompleted() && ( +
+ {collectionPaths.length === 0 && ( +
+ +

No bruno collections found in this repository.

+
+ )} + {collectionPaths.length > 0 && ( + <> +

+ {collectionPaths.length} bruno collections found. Please select the collections to open: +

+
    + {collectionPaths.map((collection) => ( +
  • + +
  • + ))} +
+ + )} +
+ )} + + )} +
+
+
+ ); +}; + +export default CloneGitRepository; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js index 14754f54a..d39a9b267 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js @@ -92,6 +92,53 @@ const FileTab = ({ } }; + const handleMultipleFiles = async (fileArray) => { + setIsLoading(true); + try { + const filesData = []; + + // Parse all files + for (const file of fileArray) { + try { + const data = await convertFileToObject(file); + + // Determine type for each file + let type = null; + if (isOpenApiSpec(data)) { + type = 'openapi'; + } else if (isWSDLCollection(data)) { + type = 'wsdl'; + } else if (isPostmanCollection(data)) { + type = 'postman'; + } else if (isInsomniaCollection(data)) { + type = 'insomnia'; + } else if (isOpenCollection(data)) { + type = 'opencollection'; + } else if (isBrunoCollection(data)) { + type = 'bruno'; + } + + if (type) { + filesData.push({ file, data, type }); + } + } catch (err) { + console.warn(`Failed to process file ${file.name}:`, err); + } + } + + if (filesData.length > 0) { + // Pass raw filesData to be processed in BulkImportCollectionLocation + handleSubmit({ filesData, type: 'multiple' }); + } else { + throw new Error('No valid collections found in the selected files'); + } + } catch (err) { + toastError(err, 'Import multiple files failed'); + } finally { + setIsLoading(false); + } + }; + const processFile = async (file) => { setIsLoading(true); try { @@ -149,7 +196,10 @@ const FileTab = ({ return; } - if (fileArray.length > 0) { + if (fileArray.length > 1) { + // Process multiple non-ZIP files normally + await handleMultipleFiles(fileArray); + } else if (fileArray.length === 1) { await processFile(fileArray[0]); } }; @@ -200,17 +250,18 @@ const FileTab = ({ ref={fileInputRef} type="file" className="hidden" + multiple onChange={handleFileInputChange} accept={acceptedFileTypes.join(',')} />

- Drop file to import or{' '} + Drop file(s) to import or{' '}

diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js new file mode 100644 index 000000000..dbd6d02a3 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { isGitRepositoryUrl } from 'utils/git'; +import toast from 'react-hot-toast'; +import Button from 'ui/Button'; +const GitHubTab = ({ + handleSubmit, + setErrorMessage +}) => { + const [urlInput, setUrlInput] = useState(''); + + const handleGitRepositoryImport = (url) => { + if (!isGitRepositoryUrl(url)) { + setErrorMessage('Please enter a valid git repository URL'); + return; + } + handleSubmit({ repositoryUrl: url, type: 'git-repository' }); + }; + + const handleFormSubmit = (e) => { + e.preventDefault(); + if (urlInput.trim()) { + handleGitRepositoryImport(urlInput.trim()); + } + }; + + return ( +

+
+ setUrlInput(e.target.value)} + placeholder="Enter Git repository URL" + className="flex-1 px-3 py-1 textbox" + /> + +
+
+ ); +}; + +export default GitHubTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js new file mode 100644 index 000000000..5e1e3be3d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .tabs { + .tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js new file mode 100644 index 000000000..7c0741a4d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common'; +import { isValidUrl } from 'utils/url/index'; +import Button from 'ui/Button'; +const UrlTab = ({ + setIsLoading, + handleSubmit, + setErrorMessage +}) => { + const [urlInput, setUrlInput] = useState(''); + + const handleUrlImport = async (event) => { + event.preventDefault(); + if (!urlInput.trim() || !isValidUrl(urlInput.trim())) { + setErrorMessage('Please enter a valid URL'); + return; + } + setIsLoading(true); + try { + const { data, specType } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() }); + // Pass raw data for all types + handleSubmit({ rawData: data, type: specType }); + } catch (err) { + console.error(err); + setErrorMessage('URL import failed. Please check the URL and try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ { + setUrlInput(e.target.value); + setErrorMessage(''); + }} + placeholder="Enter URL (OpenAPI/Swagger, Postman, or Insomnia specification)" + className="flex-1 px-3 py-1 textbox" + /> + +
+
+ ); +}; + +export default UrlTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index a1bda3e01..c33b031e6 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -1,41 +1,92 @@ import React, { useState } from 'react'; -import { IconX } from '@tabler/icons'; +import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons'; import Modal from 'components/Modal'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; import FileTab from './FileTab'; +import GitHubTab from './GitHubTab'; +import UrlTab from './UrlTab'; import FullscreenLoader from './FullscreenLoader/index'; import { useTheme } from 'providers/Theme'; +const IMPORT_TABS = { + FILE: 'file', + GITHUB: 'github', + URL: 'url' +}; + const ImportCollection = ({ onClose, handleSubmit }) => { const { theme } = useTheme(); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [tab, setTab] = useState(IMPORT_TABS.FILE); + + const handleTabSelect = (value) => () => { + setTab(value); + setErrorMessage(''); + }; + + const getTabClassname = (tabName) => { + return classnames(`flex tab items-center py-2 px-4 ${tabName}`, { + active: tabName === tab + }); + }; if (isLoading) { return ; } return ( - -
+ + +
+
+
+ + File +
+
+ + Git Repository +
+
+ + URL +
+
+
+ {errorMessage && (
{errorMessage}
setErrorMessage('')} - style={{ color: theme.status?.danger?.text || '#dc2626' }} + style={{ color: theme.status.danger.text }} >
@@ -43,12 +94,27 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
)} - -
+ {tab === IMPORT_TABS.FILE && ( + + )} + {tab === IMPORT_TABS.GITHUB && ( + + )} + {tab === IMPORT_TABS.URL && ( + + )} +
); }; diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 30b7a3ca9..836c4766c 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -24,6 +24,8 @@ import MenuDropdown from 'ui/MenuDropdown'; import ActionIcon from 'ui/ActionIcon'; import ImportCollection from 'components/Sidebar/ImportCollection'; import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; +import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation'; +import CloneGitRepository from 'components/Sidebar/CloneGitRespository'; import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index'; import CreateCollection from 'components/Sidebar/CreateCollection'; import Collections from 'components/Sidebar/Collections'; @@ -45,6 +47,8 @@ const CollectionsSection = () => { const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); + const [showCloneGitModal, setShowCloneGitModal] = useState(false); + const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); const workspaceCollections = useMemo(() => { if (!activeWorkspace) return []; @@ -57,8 +61,15 @@ const CollectionsSection = () => { }); }, [activeWorkspace, collections, workspaces]); - const handleImportCollection = ({ rawData, type, ...rest }) => { + const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => { setImportCollectionModalOpen(false); + + if (type === 'git-repository') { + setGitRepositoryUrl(repositoryUrl); + setShowCloneGitModal(true); + return; + } + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; @@ -72,14 +83,14 @@ const CollectionsSection = () => { .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); - toast.success('Collection imported successfully'); - }) - .catch((err) => { - console.error(err); - toast.error('An error occurred while importing the collection'); }); }; + const handleCloseGitModal = () => { + setShowCloneGitModal(false); + setGitRepositoryUrl(null); + }; + const handleToggleSearch = () => { setShowSearch((prev) => !prev); }; @@ -250,7 +261,7 @@ const CollectionsSection = () => { handleSubmit={handleImportCollection} /> )} - {importCollectionLocationModalOpen && importData && ( + {importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && ( { handleSubmit={handleImportCollectionLocation} /> )} + {importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && ( + setImportCollectionLocationModalOpen(false)} + handleSubmit={handleImportCollectionLocation} + /> + )} + {showCloneGitModal && ( + + )} { const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); const [importData, setImportData] = useState(null); + const [showCloneGitModal, setShowCloneGitModal] = useState(false); + const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); const workspaceCollectionsCount = workspace?.collections?.length || 0; @@ -51,8 +55,15 @@ const WorkspaceOverview = ({ workspace }) => { setImportCollectionModalOpen(true); }; - const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => { + const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => { setImportCollectionModalOpen(false); + + if (type === 'git-repository') { + setGitRepositoryUrl(repositoryUrl); + setShowCloneGitModal(true); + return; + } + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; @@ -66,14 +77,14 @@ const WorkspaceOverview = ({ workspace }) => { .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); - toast.success('Collection imported successfully'); - }) - .catch((err) => { - console.error(err); - toast.error(err.message); }); }; + const handleCloseGitModal = () => { + setShowCloneGitModal(false); + setGitRepositoryUrl(null); + }; + return ( {createCollectionModalOpen && ( @@ -87,7 +98,7 @@ const WorkspaceOverview = ({ workspace }) => { /> )} - {importCollectionLocationModalOpen && importData && ( + {importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && ( { handleSubmit={handleImportCollectionLocation} /> )} + {importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && ( + setImportCollectionLocationModalOpen(false)} + handleSubmit={handleImportCollectionLocation} + /> + )} + {showCloneGitModal && ( + + )}
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index b03fb8e51..a890f1979 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { updateCookies, - updatePreferences + updatePreferences, + setGitVersion } from 'providers/ReduxStore/slices/app'; import { addTab @@ -329,6 +330,10 @@ const useIpcEvents = () => { dispatch(updateCollectionLoadingState(val)); }); + const gitVersionListener = ipcRenderer.on('main:git-version', (val) => { + dispatch(setGitVersion(val)); + }); + return () => { removeCollectionTreeUpdateListener(); removeApiSpecTreeUpdateListener(); @@ -360,6 +365,7 @@ const useIpcEvents = () => { removeCollectionLoadingStateListener(); removePersistentEnvVariablesUpdateListener(); removeSystemResourcesListener(); + gitVersionListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 6814b13c2..e96dc26f7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -48,6 +48,8 @@ const initialState = { }, cookies: [], taskQueue: [], + gitOperationProgress: {}, + gitVersion: null, clipboard: { hasCopiedItems: false // Whether clipboard has Bruno data (for UI) }, @@ -123,6 +125,19 @@ export const appSlice = createSlice({ toggleSidebarCollapse: (state) => { state.sidebarCollapsed = !state.sidebarCollapsed; }, + updateGitOperationProgress: (state, action) => { + const { uid, data } = action.payload; + if (!state.gitOperationProgress[uid]) { + state.gitOperationProgress[uid] = { progressData: [] }; + } + state.gitOperationProgress[uid].progressData.push(data); + }, + removeGitOperationProgress: (state, action) => { + delete state.gitOperationProgress[action.payload]; + }, + setGitVersion: (state, action) => { + state.gitVersion = action.payload; + }, setClipboard: (state, action) => { // Update clipboard UI state state.clipboard.hasCopiedItems = action.payload.hasCopiedItems; @@ -164,6 +179,9 @@ export const { updateSystemProxyVariables, updateGenerateCode, toggleSidebarCollapse, + updateGitOperationProgress, + removeGitOperationProgress, + setGitVersion, setClipboard } = appSlice.actions; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 29ebb38db..fdadf4746 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -10,6 +10,7 @@ import trim from 'lodash/trim'; import path, { normalizePath } from 'utils/common/path'; import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; +import IpcErrorModal from 'components/Errors/IpcErrorModal/index'; import { findCollectionByUid, findEnvironmentInCollection, @@ -2685,19 +2686,22 @@ export const importCollection = (collection, collectionLocation, options = {}) = try { const state = getState(); const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + const isMultiple = Array.isArray(collection); - const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT); + const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT); + const importedPaths = result.success.items; - if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') { - const workspaceCollection = { - name: collection.name, - path: collectionPath - }; - - await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection); + if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') { + for (const importedItem of importedPaths) { + const workspaceCollection = { + name: importedItem.name, + path: importedItem.path + }; + await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection); + } } - resolve(collectionPath); + resolve(isMultiple ? importedPaths : importedPaths[0]); } catch (error) { reject(error); } @@ -3027,6 +3031,34 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch, }); }; +export const cloneGitRepository = (data) => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + ipcRenderer + .invoke('renderer:clone-git-repository', data) + .then((res) => { + console.log('clone done', res); + }) + .then(resolve) + .catch((err) => { + toast.custom(); + reject(); + }); + }); +}; + +export const scanForBrunoFiles = (dir) => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + ipcRenderer + .invoke('renderer:scan-for-bruno-files', dir) + .then(resolve) + .catch((err) => { + reject(); + }); + }); +}; + /** * Close tabs and delete any transient request files from the filesystem. * This thunk wraps the closeTabs reducer to handle transient file cleanup automatically. diff --git a/packages/bruno-app/src/utils/git/index.js b/packages/bruno-app/src/utils/git/index.js new file mode 100644 index 000000000..77a5762a5 --- /dev/null +++ b/packages/bruno-app/src/utils/git/index.js @@ -0,0 +1,63 @@ +import gitUrlParse from 'git-url-parse'; + +const isGitUrl = (str) => { + try { + const parsed = gitUrlParse(str); + + if (!parsed) { + return false; + } + + // Validate that it has the essential parts of a git URL and uses valid protocols + const validProtocols = ['git', 'ssh', 'http', 'https']; + return !!( + parsed + && parsed.owner + && parsed.source + && validProtocols.includes(parsed.protocol) + ); + } catch (error) { + return false; + } +}; + +export const getRepoNameFromUrl = (url) => { + try { + const parsedUrl = gitUrlParse(url); + return parsedUrl.name; + } catch (error) { + throw new Error('Invalid Git URL'); + } +}; + +export const containsGitHubToken = (remoteUrl) => { + const GITHUB_TOKEN_REGEX = /(ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{30,}/; + return GITHUB_TOKEN_REGEX.test(remoteUrl); +}; + +export const getSafeGitRemoteUrls = (remotes = []) => { + const remoteUrls = remotes + ?.map((remote) => remote?.refs?.fetch) + ?.filter((url) => typeof url === 'string' && url?.trim()?.length > 0); + + const safeRemoteUrls = remoteUrls + ?.filter((remoteUrl) => !containsGitHubToken(remoteUrl)); + return safeRemoteUrls || []; +}; + +export const isGitRepositoryUrl = (url) => { + try { + if (!url || typeof url !== 'string') { + return false; + } + + // First try the URL as-is + if (isGitUrl(url)) { + return true; + } + + return false; + } catch { + return false; + } +}; diff --git a/packages/bruno-app/src/utils/git/index.spec.js b/packages/bruno-app/src/utils/git/index.spec.js new file mode 100644 index 000000000..f371c3d07 --- /dev/null +++ b/packages/bruno-app/src/utils/git/index.spec.js @@ -0,0 +1,112 @@ +import { containsGitHubToken, getSafeGitRemoteUrls, isGitRepositoryUrl } from './index'; + +describe('containsGitHubToken', () => { + test('should return true for a URL containing a GitHub token', () => { + expect(containsGitHubToken('https://ghp_abcdefgh1234567890abcdefgh12345678@github.com')) + .toBe(true); + }); + + test('should return false for a URL without a GitHub token', () => { + expect(containsGitHubToken('https://github.com/user/repo.git')) + .toBe(false); + }); + + test('should return false for an empty string', () => { + expect(containsGitHubToken('')) + .toBe(false); + }); + + test('should return false for a null value', () => { + expect(containsGitHubToken(null)) + .toBe(false); + }); + + test('should return false for a URL with a similar but invalid token', () => { + expect(containsGitHubToken('https://ghz_abcdefgh1234567890@github.com')) + .toBe(false); + }); +}); + +describe('getSafeGitRemoteUrls', () => { + test('should filter out URLs containing GitHub tokens', () => { + const remotes = [ + { refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } }, + { refs: { fetch: 'https://github.com/user/repo.git' } }, + { refs: { fetch: 'git@github.com:user/repo.git' } } + ]; + expect(getSafeGitRemoteUrls(remotes)).toEqual([ + 'https://github.com/user/repo.git', + 'git@github.com:user/repo.git' + ]); + }); + + test('should return an empty array if all URLs contain GitHub tokens', () => { + const remotes = [ + { refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } }, + { refs: { fetch: 'https://gho_abcdefgh1234567890abcdefgh12345678@github.com' } } + ]; + expect(getSafeGitRemoteUrls(remotes)).toEqual([]); + }); + + test('should return an empty array if no valid URLs are present', () => { + const remotes = [ + { refs: { fetch: '' } }, + { refs: { fetch: null } }, + { refs: { fetch: undefined } } + ]; + expect(getSafeGitRemoteUrls(remotes)).toEqual([]); + }); + + test('should return an empty array if input is null or undefined', () => { + expect(getSafeGitRemoteUrls(null)).toEqual([]); + expect(getSafeGitRemoteUrls(undefined)).toEqual([]); + }); + + test('should ignore remotes with no fetch property', () => { + const remotes = [ + { refs: {} }, + {} + ]; + expect(getSafeGitRemoteUrls(remotes)).toEqual([]); + }); +}); + +describe('isGitRepositoryUrl', () => { + test('should return true for valid HTTPS GitHub URLs', () => { + expect(isGitRepositoryUrl('https://github.com/user/repo.git')).toBe(true); + expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true); // automatically adds .git suffix + }); + + test('should return true for valid SSH GitHub URLs', () => { + expect(isGitRepositoryUrl('git@github.com:user/repo.git')).toBe(true); + }); + + test('should return true for custom Git server URLs', () => { + expect(isGitRepositoryUrl('https://git.example.com/user/repo.git')).toBe(true); + expect(isGitRepositoryUrl('git@git.example.com:user/repo.git')).toBe(true); + }); + + test('should return false for invalid URLs', () => { + expect(isGitRepositoryUrl('')).toBe(false); + expect(isGitRepositoryUrl('not-a-url')).toBe(false); + expect(isGitRepositoryUrl('https://example.com')).toBe(false); + expect(isGitRepositoryUrl('ftp://github.com/user/repo.git')).toBe(false); + }); + + test('should return true for HTTPS URLs without .git suffix for valid Git hosts', () => { + expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true); + expect(isGitRepositoryUrl('https://gitlab.com/user/repo')).toBe(true); + expect(isGitRepositoryUrl('https://bitbucket.org/user/repo')).toBe(true); + }); + + test('should return false for null or undefined', () => { + expect(isGitRepositoryUrl(null)).toBe(false); + expect(isGitRepositoryUrl(undefined)).toBe(false); + }); + + test('should handle malformed URLs gracefully', () => { + expect(isGitRepositoryUrl('https://')).toBe(false); + expect(isGitRepositoryUrl('git@')).toBe(false); + expect(isGitRepositoryUrl('://invalid')).toBe(false); + }); +}); diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js index 6bf9272a3..3ab032f73 100644 --- a/packages/bruno-app/src/utils/importers/common.js +++ b/packages/bruno-app/src/utils/importers/common.js @@ -1,22 +1,31 @@ +import jsyaml from 'js-yaml'; import each from 'lodash/each'; import get from 'lodash/get'; +import filter from 'lodash/filter'; import cloneDeep from 'lodash/cloneDeep'; import { uuid } from 'utils/common'; import { isItemARequest } from 'utils/collections'; import { collectionSchema } from '@usebruno/schema'; import { BrunoError } from 'utils/common/error'; +import { isOpenApiSpec } from './openapi-collection'; +import { isPostmanCollection } from './postman-collection'; +import { isInsomniaCollection } from './insomnia-collection'; -export const validateSchema = (collection = {}) => { - return new Promise((resolve, reject) => { - collectionSchema - .validate(collection) - .then(() => resolve(collection)) - .catch((err) => { - console.log(err); - reject(new BrunoError('The Collection file is corrupted')); - }); - }); +export const validateSchema = async (collections = []) => { + collections = Array.isArray(collections) ? collections : [collections]; + + try { + await Promise.all( + collections.map(async (collection) => { + await collectionSchema.validate(collection); + }) + ); + return collections; + } catch (err) { + console.log(err); + throw new BrunoError('The Collection file is corrupted'); + } }; export const updateUidsInCollection = (_collection) => { @@ -66,6 +75,18 @@ export const updateUidsInCollection = (_collection) => { return collection; }; +export const filterItemsInCollection = (collection) => { + // this filters out the bruno.json item in older collection exports + collection.items = filter(collection.items, (item) => { + if (item?.name === 'bruno' && item?.type === 'json') { + return false; + } + return true; + }); + + return collection; +}; + // todo // need to eventually get rid of supporting old collection app models // 1. start with making request type a constant fetched from a single place @@ -156,7 +177,12 @@ export const transformItemsInCollection = (collection) => { }); }; - transformItems(collection.items); + if (Array.isArray(collection)) { + collection.forEach((col) => transformItems(col.items)); + } else { + transformItems(collection.items); + } + return collection; }; @@ -173,7 +199,38 @@ export const hydrateSeqInCollection = (collection) => { } }); }; - hydrateSeq(collection.items); + + if (Array.isArray(collection)) { + collection.forEach((col) => hydrateSeq(col.items)); + } else { + hydrateSeq(collection.items); + } return collection; }; + +/** + * Gets the schema type(postman, insomnia, openapi) of the CollectionJSON data + * @param {Object} data - The JSON data to get the type of + * @returns {'openapi' | 'postman' | 'insomnia' | 'unknown'} - The type of the CollectionJSON data + */ +const getCollectionSpecType = (data) => { + return isOpenApiSpec(data) ? 'openapi' : isPostmanCollection(data) ? 'postman' : isInsomniaCollection(data) ? 'insomnia' : 'unknown'; +}; + +export const fetchAndValidateApiSpecFromUrl = ({ url }) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + ipcRenderer + .invoke('renderer:fetch-api-spec', url) + .then((res) => jsyaml.load(res)) + .then((data) => { + const specType = getCollectionSpecType(data); + resolve({ data, specType: specType }); + }) + .catch((err) => { + console.error(err); + reject(new BrunoError('Failed to fetch API specification: ' + err.message)); + }); + }); +}; diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 754431ba4..3f7ec3701 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -70,6 +70,7 @@ "mime-types": "^2.1.35", "nanoid": "3.3.8", "qs": "^6.14.1", + "simple-git": "^3.22.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^9.0.0", diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 6cc1979b8..ee4d12a5c 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -41,6 +41,7 @@ const registerPreferencesIpc = require('./ipc/preferences'); const registerSystemMonitorIpc = require('./ipc/system-monitor'); const registerWorkspaceIpc = require('./ipc/workspace'); const registerApiSpecIpc = require('./ipc/apiSpec'); +const registerGitIpc = require('./ipc/git'); const collectionWatcher = require('./app/collection-watcher'); const WorkspaceWatcher = require('./app/workspace-watcher'); const ApiSpecWatcher = require('./app/apiSpecsWatcher'); @@ -403,6 +404,7 @@ app.on('ready', async () => { registerNotificationsIpc(mainWindow, collectionWatcher); registerFilesystemIpc(mainWindow); registerSystemMonitorIpc(mainWindow, systemMonitor); + registerGitIpc(mainWindow); }); // Quit the app once all windows are closed diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 3e4a8ed02..6f05725f9 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -53,7 +53,8 @@ const { isValidDotEnvFilename, isBrunoConfigFile, isBruEnvironmentConfig, - isCollectionRootBruFile + isCollectionRootBruFile, + scanForBrunoFiles } = require('../utils/filesystem'); const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common'); @@ -72,6 +73,7 @@ const collectionWatcher = require('../app/collection-watcher'); const { transformBrunoConfigBeforeSave } = require('../utils/transformBrunoConfig'); const { REQUEST_TYPES } = require('../utils/constants'); const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler'); +const { findUniqueFolderName } = require('../utils/collection-import'); const environmentSecretsStore = new EnvironmentSecretsStore(); const collectionSecurityStore = new CollectionSecurityStore(); @@ -1106,122 +1108,171 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { }); ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = DEFAULT_COLLECTION_FORMAT) => { - try { - let collectionName = sanitizeName(collection.name); - let collectionPath = path.join(collectionLocation, collectionName); + let collections = Array.isArray(collection) ? collection : [collection]; + let completedImports = 0; + let failedImports = 0; + let successfulImports = []; - if (fs.existsSync(collectionPath)) { - throw new Error(`collection: ${collectionPath} already exists`); - } + for (let coll of collections) { + try { + // Sending a "started" and "ended" event to renderer to start and stop the spinner. + mainWindow.webContents.send('main:collection-import-started', coll.uid); - const getFilenameWithFormat = (item, format) => { - if (item?.filename) { - const ext = path.extname(item.filename); - if (ext === '.bru' || ext === '.yml') { - return item.filename.replace(ext, `.${format}`); - } - return item.filename; + let collectionName = sanitizeName(coll.name); + let collectionPath = path.join(collectionLocation, collectionName); + + // Auto-rename if collection already exists + if (fs.existsSync(collectionPath)) { + const uniqueName = await findUniqueFolderName(coll.name, collectionLocation); + collectionName = sanitizeName(uniqueName); + collectionPath = path.join(collectionLocation, collectionName); + coll.name = uniqueName; } - return `${item.name}.${format}`; - }; - // Recursive function to parse the collection items and create files/folders - const parseCollectionItems = async (items = [], currentPath) => { - await Promise.all(items.map(async (item) => { - if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { - let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format)); - const content = await stringifyRequestViaWorker(item, { format }); - const filePath = path.join(currentPath, sanitizedFilename); + const getFilenameWithFormat = (item, format) => { + if (item?.filename) { + const ext = path.extname(item.filename); + if (ext === '.bru' || ext === '.yml') { + return item.filename.replace(ext, `.${format}`); + } + return item.filename; + } + return `${item.name}.${format}`; + }; + + // Recursive function to parse the collection items and create files/folders + const parseCollectionItems = async (items = [], currentPath) => { + await Promise.all(items.map(async (item) => { + if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { + let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format)); + const content = await stringifyRequestViaWorker(item, { format }); + const filePath = path.join(currentPath, sanitizedFilename); + safeWriteFileSync(filePath, content); + } + if (item.type === 'folder') { + let sanitizedFolderName = sanitizeName(item?.filename || item?.name); + const folderPath = path.join(currentPath, sanitizedFolderName); + fs.mkdirSync(folderPath); + + if (item?.root?.meta?.name) { + const folderFilePath = path.join(folderPath, `folder.${format}`); + item.root.meta.seq = item.seq; + const folderContent = await stringifyFolder(item.root, { format }); + safeWriteFileSync(folderFilePath, folderContent); + } + + if (item.items && item.items.length) { + await parseCollectionItems(item.items, folderPath); + } + } + // Handle items of type 'js' + if (item.type === 'js') { + let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`); + const filePath = path.join(currentPath, sanitizedFilename); + safeWriteFileSync(filePath, item.fileContent); + } + })); + }; + + const parseEnvironments = async (environments = [], collectionPath) => { + const envDirPath = path.join(collectionPath, 'environments'); + if (!fs.existsSync(envDirPath)) { + fs.mkdirSync(envDirPath); + } + + await Promise.all(environments.map(async (env) => { + const content = await stringifyEnvironment(env, { format }); + let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`); + const filePath = path.join(envDirPath, sanitizedEnvFilename); safeWriteFileSync(filePath, content); - } - if (item.type === 'folder') { - let sanitizedFolderName = sanitizeName(item?.filename || item?.name); - const folderPath = path.join(currentPath, sanitizedFolderName); - fs.mkdirSync(folderPath); + })); + }; - if (item?.root?.meta?.name) { - const folderFilePath = path.join(folderPath, `folder.${format}`); - item.root.meta.seq = item.seq; - const folderContent = await stringifyFolder(item.root, { format }); - safeWriteFileSync(folderFilePath, folderContent); - } + const getBrunoJsonConfig = (collection) => { + let brunoConfig = collection.brunoConfig; - if (item.items && item.items.length) { - await parseCollectionItems(item.items, folderPath); - } + if (!brunoConfig) { + brunoConfig = { + version: '1', + name: collection.name, + type: 'collection', + ignore: ['node_modules', '.git'] + }; } - // Handle items of type 'js' - if (item.type === 'js') { - let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`); - const filePath = path.join(currentPath, sanitizedFilename); - safeWriteFileSync(filePath, item.fileContent); - } - })); - }; - const parseEnvironments = async (environments = [], collectionPath) => { - const envDirPath = path.join(collectionPath, 'environments'); - if (!fs.existsSync(envDirPath)) { - fs.mkdirSync(envDirPath); + return brunoConfig; + }; + + await createDirectory(collectionPath); + + const uid = generateUidBasedOnHash(collectionPath); + const brunoConfig = getBrunoJsonConfig(coll); + + if (format === 'yml') { + brunoConfig.opencollection = '1.0.0'; + const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format }); + await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent); + } else if (format === 'bru') { + const stringifiedBrunoConfig = await stringifyJson(brunoConfig); + await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig); + + const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format }); + await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent); + } else { + throw new Error(`Invalid format: ${format}`); } - await Promise.all(environments.map(async (env) => { - const content = await stringifyEnvironment(env, { format }); - let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`); - const filePath = path.join(envDirPath, sanitizedEnvFilename); - safeWriteFileSync(filePath, content); - })); - }; + // create folder and files based on collection + await parseCollectionItems(coll.items, collectionPath); + await parseEnvironments(coll.environments, collectionPath); - const getBrunoJsonConfig = (collection) => { - let brunoConfig = collection.brunoConfig; + const { size, filesCount } = await getCollectionStats(collectionPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; - if (!brunoConfig) { - brunoConfig = { - version: '1', - name: collection.name, - type: 'collection', - ignore: ['node_modules', '.git'] - }; - } + mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); + ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); - return brunoConfig; - }; + mainWindow.webContents.send('main:collection-import-ended', coll.uid); - await createDirectory(collectionPath); + successfulImports.push({ + path: collectionPath, + name: coll.name + }); + // Increment completed imports + completedImports++; + } catch (error) { + mainWindow.webContents.send('main:collection-import-failed', coll.uid, { + message: `Error ${error.message}` + }); + console.error(`Failed to import collection: ${coll.name}, Error: ${error.message}`); - const uid = generateUidBasedOnHash(collectionPath); - let brunoConfig = getBrunoJsonConfig(collection); + // Increment failed imports + failedImports++; - if (format === 'yml') { - brunoConfig.opencollection = '1.0.0'; - const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format }); - await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent); - } else if (format === 'bru') { - const stringifiedBrunoConfig = await stringifyJson(brunoConfig); - await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig); - - const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format }); - await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent); - } else { - throw new Error(`Invalid format: ${format}`); + // Continue with next collection instead of breaking + continue; } - - const { size, filesCount } = await getCollectionStats(collectionPath); - brunoConfig.size = size; - brunoConfig.filesCount = filesCount; - - mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); - ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); - - // create folder and files based on collection - await parseCollectionItems(collection.items, collectionPath); - await parseEnvironments(collection.environments, collectionPath); - - return collectionPath; - } catch (error) { - return Promise.reject(error); } + + // Send final status when all collections have been processed (either succeeded or failed) + if ((completedImports + failedImports) === collections.length) { + mainWindow.webContents.send('main:all-collections-import-ended', { + message: `Import completed. ${completedImports} collections imported successfully, ${failedImports} failed.`, + status: { + total: collections.length, + succeeded: completedImports, + failed: failedImports + } + }); + } + + return { + success: { + count: completedImports, + items: successfulImports + } + }; }); ipcMain.handle('renderer:clone-folder', async (event, itemFolder, collectionPath, collectionPathname) => { @@ -2320,6 +2371,14 @@ const registerMainEventHandlers = (mainWindow, watcher) => { app.addRecentDocument(pathname); }); + ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => { + try { + return scanForBrunoFiles(dir); + } catch (error) { + throw new Error(error.message); + } + }); + // The app listen for this event and allows the user to save unsaved requests before closing the app ipcMain.on('main:start-quit-flow', () => { mainWindow.webContents.send('main:start-quit-flow'); diff --git a/packages/bruno-electron/src/ipc/git.js b/packages/bruno-electron/src/ipc/git.js new file mode 100644 index 000000000..08ad81515 --- /dev/null +++ b/packages/bruno-electron/src/ipc/git.js @@ -0,0 +1,22 @@ +const { ipcMain } = require('electron'); +const { cloneGitRepository } = require('../utils/git'); +const { createDirectory, removeDirectory } = require('../utils/filesystem'); + +const registerGitIpc = (mainWindow) => { + ipcMain.handle('renderer:clone-git-repository', async (event, { url, path, processUid }) => { + let directoryCreated = false; + try { + await createDirectory(path); + directoryCreated = true; + await cloneGitRepository(mainWindow, { url, path, processUid }); + return 'Repository cloned successfully'; + } catch (error) { + if (directoryCreated) { + await removeDirectory(path); + } + return Promise.reject(error); + } + }); +}; + +module.exports = registerGitIpc; diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index 07deec517..f9d1b387f 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -1,5 +1,6 @@ const { ipcMain, nativeTheme } = require('electron'); const { getPreferences, savePreferences } = require('../store/preferences'); +const { getGitVersion } = require('../utils/git'); const { globalEnvironmentsStore } = require('../store/global-environments'); const { getCachedSystemProxy, refreshSystemProxy } = require('../store/system-proxy'); @@ -20,6 +21,9 @@ const registerPreferencesIpc = (mainWindow) => { console.error(error); } + const gitVersion = await getGitVersion(); + mainWindow.webContents.send('main:git-version', gitVersion); + ipcMain.emit('main:renderer-ready', mainWindow); }); diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 7e88f0c95..84b758ca9 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -474,6 +474,33 @@ const isCollectionRootBruFile = (pathname, collectionPath) => { return dirname === collectionPath && basename === 'collection.bru'; }; +const scanForBrunoFiles = async (dir) => { + const brunoFolders = []; + + const scanDir = (currentDir) => { + const files = fs.readdirSync(currentDir); + + if (files && files.length) { + files.forEach((file) => { + const fullPath = path.join(currentDir, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + if (['node_modules', '.git'].includes(file)) { + return; + } + scanDir(fullPath); + } else if (file === 'bruno.json') { + brunoFolders.push(currentDir); + } + }); + } + }; + + scanDir(dir); + return brunoFolders; +}; + module.exports = { DEFAULT_GITIGNORE, isValidPathname, @@ -514,5 +541,6 @@ module.exports = { isValidDotEnvFilename, isBrunoConfigFile, isBruEnvironmentConfig, - isCollectionRootBruFile + isCollectionRootBruFile, + scanForBrunoFiles }; diff --git a/packages/bruno-electron/src/utils/git.js b/packages/bruno-electron/src/utils/git.js new file mode 100644 index 000000000..6901b7f99 --- /dev/null +++ b/packages/bruno-electron/src/utils/git.js @@ -0,0 +1,1814 @@ +const simpleGit = require('simple-git'); +const fs = require('fs'); +const path = require('path'); +const { exec } = require('child_process'); +const { parseRequest } = require('@usebruno/filestore'); + +let collectionPathToGitRootPathMap = new Map(); + +const simpleGitInstances = new Map(); + +const getGitVersion = () => { + return new Promise((resolve, reject) => { + exec('git --version', (error, stdout, stderr) => { + if (error) { + return resolve(null); + } + const gitVersion = stdout.trim(); + return resolve(gitVersion); + }); + }); +}; + +const getSimpleGitInstanceForPath = (gitRootPath) => { + let git = simpleGitInstances.get(gitRootPath); + if (!git) { + git = simpleGit(gitRootPath); + simpleGitInstances.set(gitRootPath, git); + } + return git; +}; + +const handleGitOutput = ({ win, processUid, sendStdout = false }) => (command, stdout, stderr) => { + const sendProgressUpdate = (data) => { + win.webContents.send('main:update-git-operation-progress', { + uid: processUid, + data: data.toString() + }); + }; + + stderr.on('data', sendProgressUpdate); + + if (sendStdout) { + stdout.on('data', sendProgressUpdate); + } +}; + +const findGitRootPath = (collectionPath) => { + const gitPath = path.join(collectionPath, '.git'); + try { + if (fs.existsSync(gitPath)) { + return gitPath?.split('.git')?.[0]; + } else { + const parentDir = path.dirname(collectionPath); + if (parentDir === collectionPath) { + return null; + } else { + return findGitRootPath(parentDir); + } + } + } catch (err) { + console.error('Error finding .git path:', err); + return null; + } +}; + +const getCollectionGitRootPath = (collectionPath) => { + let savedGitRootPath = collectionPathToGitRootPathMap.get(collectionPath); + if (savedGitRootPath) { + return savedGitRootPath; + } + let gitRootPath = findGitRootPath(collectionPath); + collectionPathToGitRootPathMap.set(collectionPath, gitRootPath); + return gitRootPath; +}; + +const getCollectionGitRepoUrl = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.listRemote(['--get-url', 'origin'], (err, data) => { + if (err) { + reject(err); + return; + } + resolve(data.trim()); + }); + }); +}; + +const initGit = async (gitRootPath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + await git.init(); + // Create and checkout main branch -> This is specific for use with Bruno + return await git.raw(['branch', '-M', 'main']); +}; + +const stageChanges = async (gitRootPath, files) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.add(files, (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +}; + +const unstageChanges = async (gitRootPath, files) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + // First check the status to see which files are actually staged + git.status(['--porcelain'], (err, status) => { + if (err) { + reject(err); + return; + } + + // Filter files to only include those that are actually staged + const stagedFiles = files.filter((fullPath) => { + const relativePath = path.relative(gitRootPath, fullPath); + // Normalize path separators for cross-platform compatibility + const normalizedPath = relativePath.replace(/\\/g, '/'); + return status.files.some((file) => + file.path === normalizedPath + && (file.index === 'M' || file.index === 'A' || file.index === 'D') + ); + }); + + // If no files are actually staged, just resolve + if (stagedFiles.length === 0) { + resolve(); + return; + } + + // Unstage only the files that are actually staged + git.reset(['HEAD', '--', ...stagedFiles], (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); + }); +}; + +const discardChanges = async (gitRootPath, filePaths) => { + return new Promise(async (resolve, reject) => { + try { + const git = getSimpleGitInstanceForPath(gitRootPath); + + // Get current git status to categorize files + git.status(['--porcelain'], async (err, status) => { + if (err) { + reject(err); + return; + } + + // Create a map of file paths to their status + const fileStatusMap = {}; + status.files.forEach((file) => { + fileStatusMap[file.path] = file; + }); + + // Categorize files based on their git status + const trackedFiles = []; + const untrackedFiles = []; + + filePaths.forEach((filePath) => { + // Normalize paths for comparison + const relativePath = filePath.startsWith(gitRootPath) + ? path.relative(gitRootPath, filePath) : filePath; + + // Normalize path separators for cross-platform compatibility + const normalizedPath = relativePath.replace(/\\/g, '/'); + const fileStatus = fileStatusMap[normalizedPath]; + + // If the file is untracked, we need to delete it from the filesystem + // ? means untracked + if (fileStatus && fileStatus.working_dir === '?') { + // Untracked file - needs to be deleted from filesystem + untrackedFiles.push(filePath); + } else if (fileStatus) { + // Tracked file - can be discarded with git checkout + trackedFiles.push(filePath); + } else { + // File not in status - might be already deleted, renamed, or doesn't exist + console.warn(`File not found in git status: ${relativePath}. File may have been already deleted or moved.`); + + // Check if it's an absolute path that needs to be treated as untracked + if (filePath.startsWith(gitRootPath) && fs.existsSync(filePath)) { + console.log(`Treating unknown file as untracked: ${relativePath}`); + untrackedFiles.push(filePath); + } + } + }); + + // Handle tracked and untracked files sequentially + try { + // Handle tracked files with git checkout + if (trackedFiles.length > 0) { + await new Promise((checkoutResolve, checkoutReject) => { + git.checkout(trackedFiles, (err, res) => { + if (err) { + console.error('Error discarding tracked files:', err); + checkoutReject(err); + } else { + console.log(`Discarded ${trackedFiles.length} tracked files`); + checkoutResolve(res); + } + }); + }); + } + + // Handle untracked files by deleting them from filesystem + if (untrackedFiles.length > 0) { + for (const filePath of untrackedFiles) { + const fullPath = filePath.startsWith(gitRootPath) ? filePath : path.join(gitRootPath, filePath); + + // Check if file exists before trying to delete + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + console.log(`Deleted untracked file: ${fullPath}`); + } + } + } + + resolve({ + trackedFilesDiscarded: trackedFiles.length, + untrackedFilesDeleted: untrackedFiles.length + }); + } catch (discardError) { + console.error('Error during discard operation:', discardError); + reject(discardError); + } + }); + } catch (gitStatusError) { + reject(gitStatusError); + } + }); +}; + +const commitChanges = async (gitRootPath, message) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.commit(message, (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +}; + +const getStagedFileDiff = async (gitRootPath, filePath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.diff(['--no-prefix', '--staged', '--', filePath], (err, stagedChanges) => { + if (err) { + reject(err); + return; + } + resolve(stagedChanges); + }); + }); +}; + +const getRenamedFileDiff = async (gitRootPath, file) => { + return new Promise((resolve, reject) => { + const git = simpleGit(gitRootPath); + git.diff(['--staged', '--', file.from, file.to], (err, stagedChanges) => { + if (err) { + reject(err); + return; + } + resolve(stagedChanges); + }); + }); +}; + +const getUnstagedFileDiff = async (gitRootPath, filePath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + git.status((err, status) => { + if (err) { + reject(err); + return; + } + + const isFileTracked = status.files.some((file) => { + const statusFilePath = path.join(gitRootPath, file.path); + return filePath === statusFilePath && file.index !== '?' && file.working_dir !== '?'; + }); + + if (isFileTracked) { + git.diff(['--no-prefix', '--diff-filter=ACMD', '--', filePath], (err, tracked) => { + if (err) { + reject(err); + return; + } + resolve(tracked); + }); + } else { + fs.readFile(filePath, 'utf8', (err, content) => { + if (err) { + reject(err); + return; + } + + const prefixedLines = content + .split('\n') + .map((line) => `+${line}`); + const lineCount = prefixedLines.length; + const lines = prefixedLines.join('\n'); + + let diff + = [ + `diff --git a/${filePath} b/${filePath}`, + `new file mode 100644`, + `--- a/${filePath}`, + `+++ b/${filePath}`, + `@@ -0,0 +1,${lineCount} @@`, + `${lines}` + ].join('\n') + '\n'; + + resolve(diff); + }); + } + }); + }); +}; + +const getCollectionGitBranches = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.branchLocal((err, branches) => { + if (err) { + reject(err); + return; + } + resolve(branches.all); + }); + }); +}; + +const getCurrentGitBranch = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.branchLocal((err, branches) => { + if (err) { + reject(err); + return; + } + resolve(branches.current); + }); + }); +}; + +const getDefaultGitBranch = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.raw(['symbolic-ref', '--short', 'HEAD'], (err, branch) => { + if (err) { + reject(err); + return; + } + resolve(branch.trim()); + }); + }); +}; + +const checkoutGitBranch = async (win, { gitRootPath, branchName, processUid, shouldCreate = false }) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.outputHandler(handleGitOutput({ win, processUid })); + + const checkoutArgs = shouldCreate ? ['-b', branchName, '--progress'] : branchName; + git.checkout(checkoutArgs, (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +}; + +const checkoutRemoteGitBranch = async (win, { gitRootPath, remoteName, branchName, processUid }) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.outputHandler(handleGitOutput({ win, processUid })); + + const remoteBranchName = `${remoteName}/${branchName}`; + + // Check if the remote branch exists + git.branch(['-r'], async (err, branches) => { + if (err) { + reject(err); + return; + } + + const remoteBranches = branches.all.map((branch) => branch.trim()); + const remoteBranchExists = remoteBranches.includes(remoteBranchName); + + if (remoteBranchExists) { + try { + const localBranches = await getCollectionGitBranches(gitRootPath); + const localBranchExists = localBranches.includes(branchName); + if (localBranchExists) { + // Set the local branch to track the remote branch + git.branch(['--set-upstream-to', remoteBranchName, branchName], async (err, res) => { + if (err) { + reject(err); + } else { + git.checkout(branchName, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + } + }); + } else { + git.checkout(['-b', branchName, '--track', remoteBranchName, '--progress'], (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + } + } catch (err) { + reject(err); + } + } else { + reject(new Error(`Remote branch ${remoteBranchName} does not exist`)); + } + }); + }); +}; + +const getCollectionGitLogs = async (gitRootPath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + try { + // Get logs with shortstat for file change info + const result = await git.raw([ + 'log', + '--format=%H|%s|%an|%aI', + '--shortstat', + '-n', '500' + ]); + + if (!result || !result.trim()) { + return []; + } + + const commits = []; + const lines = result.split('\n'); + let currentCommit = null; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Check if this is a commit line (contains | separators) + if (trimmedLine.includes('|')) { + // If we have a pending commit, push it + if (currentCommit) { + commits.push(currentCommit); + } + + const parts = trimmedLine.split('|'); + if (parts.length >= 4) { + currentCommit = { + hash: parts[0], + message: parts[1], + author_name: parts[2], + date: parts[3], + filesChanged: 0, + insertions: 0, + deletions: 0 + }; + } + } else if (currentCommit && trimmedLine.includes('changed')) { + // This is a shortstat line, parse it + // Format: " 3 files changed, 45 insertions(+), 12 deletions(-)" + const filesMatch = trimmedLine.match(/(\d+) files? changed/); + const insertionsMatch = trimmedLine.match(/(\d+) insertions?\(\+\)/); + const deletionsMatch = trimmedLine.match(/(\d+) deletions?\(-\)/); + + if (filesMatch) currentCommit.filesChanged = parseInt(filesMatch[1], 10); + if (insertionsMatch) currentCommit.insertions = parseInt(insertionsMatch[1], 10); + if (deletionsMatch) currentCommit.deletions = parseInt(deletionsMatch[1], 10); + } + } + + // Push the last commit if exists + if (currentCommit) { + commits.push(currentCommit); + } + + return commits; + } catch (err) { + console.error('Error getting git logs:', err); + return []; + } +}; + +const getCollectionGitTagsWithDetails = (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git + .tags(['-l', '--format=%(refname:short)||%(creatordate)||%(creator)']) + .then((tags) => { + const tagDetails = tags.all?.map((tag) => { + const [name, date, author] = tag.split('||'); + return { name, date, author }; + }); + resolve(tagDetails); + }) + .catch(reject); + }); +}; + +const canPush = async (gitRootPath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + const branch = await git.revparse(['--abbrev-ref', 'HEAD']); + const remote = await git.listRemote(['--get-url', 'origin']); + + if (!remote) { + throw new Error('Remote not configured'); + } + + const remoteInfo = await git.lsRemote(['--refs', remote]); + const logs = await git.log({ maxCount: 1 }); + const localHead = logs.latest.hash; + const remoteRefs = remoteInfo.split('\n'); + const remoteHeads = remoteRefs.reduce((acc, ref) => { + const [hash, refName] = ref.split('\t'); + acc[refName.replace('refs/heads/', '')] = hash; + return acc; + }, {}); + const remoteHead = remoteHeads[branch]; + + if (localHead === remoteHead) { + return false; + } + + return true; +}; + +const pushGitChanges = async (win, { gitRootPath, processUid, remote, remoteBranch }) => { + return new Promise(async (resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true })); + + try { + // Check if the local branch is tracking a remote branch + git.branch((err, branchSummary) => { + if (err) { + reject(err); + return; + } + + const currentBranch = branchSummary.branches[remoteBranch]; + + if (!currentBranch) { + reject(new Error(`Branch ${remoteBranch} does not exist.`)); + return; + } + + const trackingBranch = currentBranch.tracking; + + if (!trackingBranch) { + // Set the upstream tracking branch + git.push(['--set-upstream', remote, remoteBranch], (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + } else { + // Push the local branch to the remote + git.push(remote, remoteBranch, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + } + }); + } catch (error) { + reject(error); + } + }); +}; + +const pullGitChanges = async (win, data) => { + const { gitRootPath, processUid, remote, remoteBranch, strategy } = data; + if (strategy !== '--no-rebase' && strategy !== '--ff-only') { + throw new Error('Invalid strategy'); + } + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true })).pull(remote, remoteBranch, [strategy], (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }); +}; + +async function getChangedFilesInCollectionGit(_gitRootPath, _collectionPath) { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(_gitRootPath); + git.status(['--porcelain', _gitRootPath], async (err, status) => { + if (err) { + reject(err); + return; + } + + const totalFiles = status?.files?.length || 0; + if (totalFiles > 5000) { + return resolve({ + staged: [], + unstaged: [], + totalFiles, + tooManyFiles: true + }); + } + + const unstaged = await Promise.all( + status.files + .filter( + (file) => file.index === '?' || file.index === ' ' || file.working_dir === '?' || file.working_dir === 'M' + ) + .map(async (file) => { + return { path: file.path, type: 'unstaged', fileIndex: file.index, working_dir: file.working_dir }; + }) + ); + + const renamed = await Promise.all( + status.renamed.map(async (file) => { + return { path: file.to, to: file.to, from: file.from, type: 'renamed', fileIndex: 'R', working_dir: '' }; + }) + ); + + const staged = await Promise.all( + status.files + .filter( + (file) => + (file.index === 'M' || file.index === 'A' || file.index === 'D') + && (file.working_dir === 'M' || file.working_dir === ' ') + ) + .map(async (file) => { + return { path: file.path, type: 'staged', fileIndex: file.index, working_dir: file.working_dir }; + }) + ); + + const conflicted = await Promise.all( + status.files.filter((file) => file.index === 'U' || file.working_dir === 'U').map(async (file) => { + return { path: file.path, type: 'conflicted', fileIndex: file.index, working_dir: file.working_dir }; + }) || [] + ); + + resolve({ + staged: [...staged, ...renamed], + unstaged, + totalFiles, + tooManyFiles: false, + conflicted + }); + }); + }); +} + +const getCollectionGitData = async (gitRootPath, collectionPath) => { + if (!gitRootPath) return {}; + const [branches, currentGitBranch, defaultGitBranch, gitRepoUrl] = await Promise.all([ + getCollectionGitBranches(gitRootPath), + getCurrentGitBranch(gitRootPath), + getDefaultGitBranch(gitRootPath), + getCollectionGitRepoUrl(gitRootPath) + ]); + + const logs = branches.length ? await getCollectionGitLogs(gitRootPath) : []; + + return { + gitRootPath, + gitRepoUrl, + branches, + currentGitBranch, + defaultGitBranch, + logs + }; +}; + +const cloneGitRepository = async (win, data) => { + return new Promise((resolve, reject) => { + const { url, path, processUid } = data; + const git = getSimpleGitInstanceForPath(path); + + git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true })); + git.clone(url, path, ['--progress'], (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +}; + +const fetchRemotes = (gitRootPath) => { + return new Promise((resolve, reject) => { + if (!gitRootPath) return resolve([]); + const git = getSimpleGitInstanceForPath(gitRootPath); + git.getRemotes(true) + .then((remoteList) => { + resolve(remoteList); + }) + .catch((err) => { + console.error('Error fetching remotes:', err); + reject(err); + }); + }); +}; + +const fetchChanges = (gitRootPath, remote = 'origin') => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.fetch(remote, (err, res) => { + if (err) { + reject(err); + return; + } + resolve(res); + }); + }); +}; + +const fetchRemoteBranches = ({ gitRootPath, remote }) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.branch(['-r'], (err, branches) => { + if (err) { + reject(err); + } else { + const branchNames = branches?.all + .filter((branch) => branch.startsWith(`${remote}/`)) + .map((branch) => branch.slice(remote.length + 1)); + resolve(branchNames); + } + }); + }); +}; + +const addRemote = ({ gitRootPath, remoteName, remoteUrl }) => { + return new Promise(async (resolve, reject) => { + try { + const git = getSimpleGitInstanceForPath(gitRootPath); + console.log('Adding remote:', { gitRootPath, remoteName, remoteUrl }); + await git.addRemote(remoteName, remoteUrl); + const remotes = await fetchRemotes(gitRootPath); + resolve(remotes); + } catch (err) { + console.error('Error adding remote:', err); + reject(err); + } + }); +}; + +const removeRemote = ({ gitRootPath, remoteName }) => { + return new Promise(async (resolve, reject) => { + try { + const git = getSimpleGitInstanceForPath(gitRootPath); + console.log('Removing remote:', { gitRootPath, remoteName }); + await git.removeRemote(remoteName); + const remotes = await fetchRemotes(gitRootPath); + resolve(remotes); + } catch (err) { + console.error('Error removing remote:', err); + reject(err); + } + }); +}; + +const getBehindCount = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + // First try to get status which includes tracking info and counts + git.status((err, status) => { + if (err) { + reject(err); + return; + } + + // Check if we have tracking branch information + const trackingBranch = status.tracking; + if (!trackingBranch) { + // No tracking branch set + resolve({ + behind: 0, + commits: [] + }); + return; + } + + // Use status.behind if available, otherwise calculate manually + const behindCount = status.behind || 0; + + if (behindCount === 0) { + resolve({ + behind: 0, + commits: [] + }); + return; + } + + // Get the actual commits that are behind + git.log(['HEAD..' + trackingBranch], (err, log) => { + if (err) { + // If log fails, return the count from status but empty commits + resolve({ + behind: behindCount, + commits: [] + }); + return; + } + + const commits = log.all.map((commit) => ({ + hash: commit.hash, + message: commit.message, + author: commit.author_name, + time: new Date(commit.date).toLocaleString() + })); + + resolve({ + behind: behindCount, + commits + }); + }); + }); + }); +}; + +const getAheadCount = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + // First try to get status which includes tracking info and counts + git.status((err, status) => { + if (err) { + reject(err); + return; + } + + // Check if we have tracking branch information + const trackingBranch = status.tracking; + if (!trackingBranch) { + // No tracking branch set - get all local commits as "ahead" + git.log(['HEAD'], (err, allLog) => { + if (err) { + resolve({ + ahead: 0, + commits: [] + }); + return; + } + + const commits = allLog.all.map((commit) => ({ + hash: commit.hash, + message: commit.message, + author: commit.author_name, + time: new Date(commit.date).toLocaleString() + })); + + resolve({ + ahead: commits.length, + commits + }); + }); + return; + } + + // Use status.ahead if available, otherwise calculate manually + const aheadCount = status.ahead || 0; + + if (aheadCount === 0) { + resolve({ + ahead: 0, + commits: [] + }); + return; + } + + // Get commits that are ahead (in local but not on remote) + git.log([trackingBranch + '..HEAD'], (err, log) => { + if (err) { + // If remote doesn't exist, get all local commits (they're all "ahead") + git.log(['HEAD'], (err, allLog) => { + if (err) { + resolve({ + ahead: aheadCount, + commits: [] + }); + return; + } + + const commits = allLog.all.map((commit) => ({ + hash: commit.hash, + message: commit.message, + author: commit.author_name, + time: new Date(commit.date).toLocaleString() + })); + + resolve({ + ahead: aheadCount, + commits + }); + }); + return; + } + + const commits = log.all.map((commit) => ({ + hash: commit.hash, + message: commit.message, + author: commit.author_name, + time: new Date(commit.date).toLocaleString() + })); + + resolve({ + ahead: aheadCount, + commits + }); + }); + }); + }); +}; + +const getAheadBehindCount = async (gitRootPath) => { + try { + const [behindStatus, aheadStatus] = await Promise.all([ + getBehindCount(gitRootPath), + getAheadCount(gitRootPath) + ]); + + return { + behind: behindStatus.behind, + ahead: aheadStatus.ahead, + behindCommits: behindStatus.commits, + aheadCommits: aheadStatus.commits + }; + } catch (error) { + console.error('Error getting ahead/behind count:', error); + // Return safe defaults + return { + behind: 0, + ahead: 0, + behindCommits: [], + aheadCommits: [] + }; + } +}; + +const abortConflictResolution = async (gitRootPath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + if (fs.existsSync(path.join(gitRootPath, '.git', 'MERGE_HEAD'))) { + git.raw(['merge', '--abort'], (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + } else { + reject(new Error('No merge in progress')); + } + }); +}; + +const continueMerge = async (gitRootPath, conflictedFiles, commitMessage) => { + return new Promise(async (resolve, reject) => { + try { + const fsPromises = require('fs/promises'); + + // Step 1: Write all conflicted files' final state to disk + for (const file of conflictedFiles) { + // file.path is relative to gitRootPath, convert to absolute path + const fullFilePath = path.join(gitRootPath, file.path); + + // Ensure directory exists + const dir = path.dirname(fullFilePath); + await fsPromises.mkdir(dir, { recursive: true }); + + // Write the resolved content + await fsPromises.writeFile(fullFilePath, file.content, 'utf8'); + } + + // Step 2: Stage the conflicted files + const filePaths = conflictedFiles.map((f) => f.path); + const fullPaths = filePaths.map((p) => path.join(gitRootPath, p)); + await stageChanges(gitRootPath, fullPaths); + + // Step 3: Write commit message to .git/MERGE_MSG + const mergeMsgPath = path.join(gitRootPath, '.git', 'MERGE_MSG'); + await fsPromises.writeFile(mergeMsgPath, commitMessage, 'utf8'); + + // Step 4: Call git merge --continue + exec('git -c core.editor=: merge --continue', { cwd: gitRootPath }, (err, stdout) => { + if (err) { + reject(err); + return; + } + resolve(stdout); + }); + } catch (error) { + reject(error); + } + }); +}; + +const getCommitFiles = async (gitRootPath, commitHash) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + // Get the list of files changed in this commit with stats + git.raw(['show', '--stat', '--name-status', '--format=', commitHash], (err, result) => { + if (err) { + reject(err); + return; + } + + const lines = result.trim().split('\n').filter((line) => line.trim()); + const files = []; + + for (const line of lines) { + // Parse name-status format: Mfilename or Afilename or Dfilename + const match = line.match(/^([AMDRC])\t(.+)$/); + if (match) { + const [, status, filePath] = match; + files.push({ + path: filePath, + status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'M' ? 'modified' : status === 'R' ? 'renamed' : 'changed' + }); + } + } + + resolve(files); + }); + }); +}; + +const getCommitFileDiff = async (gitRootPath, commitHash, filePath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + // Get the diff for a specific file in a commit (compare with parent) + git.raw(['show', '--no-prefix', '-p', commitHash, '--', filePath], (err, diff) => { + if (err) { + reject(err); + return; + } + resolve(diff); + }); + }); +}; + +/** + * Get the list of files changed between two commits + * @param {string} gitRootPath - Path to git repository + * @param {string} fromCommit - Base commit hash (older) + * @param {string} toCommit - Target commit hash (newer) + * @returns {Promise} List of changed files with status + */ +const getCommitCompareFiles = async (gitRootPath, fromCommit, toCommit) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + // Get the list of files changed between two commits + git.raw(['diff', '--name-status', fromCommit, toCommit], (err, result) => { + if (err) { + reject(err); + return; + } + + const lines = result.trim().split('\n').filter((line) => line.trim()); + const files = []; + + for (const line of lines) { + // Parse name-status format: Mfilename or Afilename or Dfilename + const match = line.match(/^([AMDRC])\t(.+)$/); + if (match) { + const [, status, filePath] = match; + files.push({ + path: filePath, + status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'M' ? 'modified' : status === 'R' ? 'renamed' : 'changed' + }); + } + } + + resolve(files); + }); + }); +}; + +/** + * Get the diff for a specific file between two commits + * @param {string} gitRootPath - Path to git repository + * @param {string} fromCommit - Base commit hash (older) + * @param {string} toCommit - Target commit hash (newer) + * @param {string} filePath - Path to the file + * @returns {Promise} Diff string + */ +const getCommitCompareFileDiff = async (gitRootPath, fromCommit, toCommit, filePath) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + // Get the diff for a specific file between two commits + git.raw(['diff', '--no-prefix', fromCommit, toCommit, '--', filePath], (err, diff) => { + if (err) { + reject(err); + return; + } + resolve(diff); + }); + }); +}; + +/** + * Get git history for a specific file + * @param {string} gitRootPath - Path to git repository + * @param {string} filePath - Path to the file (relative to git root) + * @returns {Promise} List of commits that touched this file + */ +const getFileGitHistory = async (gitRootPath, filePath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + try { + const result = await git.raw([ + 'log', + '--format=%H|%s|%an|%aI', + '--follow', + '-n', '100', + '--', filePath + ]); + + if (!result || !result.trim()) { + return []; + } + + const commits = []; + const lines = result.trim().split('\n'); + + for (const line of lines) { + if (line.includes('|')) { + const [hash, message, author, date] = line.split('|'); + commits.push({ + hash, + message, + author_name: author, + date + }); + } + } + + return commits; + } catch (err) { + console.error('Error getting file git history:', err); + return []; + } +}; + +/** + * Get git graph data for visualization + * Gets commits from branch with parent info in a single git log call + * Only includes branch commits that fall within the time range of the main line + */ +/** + * Create a new stash with a message + * @param {string} gitRootPath - Path to git repository + * @param {string} message - Stash message/identifier + * @returns {Promise} + */ +const createStash = async (gitRootPath, message) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + // Use --include-untracked to stash untracked files as well + git.stash(['push', '--include-untracked', '-m', message], (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); +}; + +/** + * Get stash diff stats (files changed, insertions, deletions) + * Includes both tracked and untracked files + * @param {object} git - simple-git instance + * @param {number} stashIndex - Index of the stash + * @returns {Promise} Object with filesChanged, insertions, deletions + */ +const getStashStats = async (git, stashIndex) => { + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + + try { + // Get stats for tracked files + const trackedResult = await git.raw(['stash', 'show', '--numstat', `stash@{${stashIndex}}`]); + + if (trackedResult) { + const lines = trackedResult.trim().split('\n').filter((line) => line.trim()); + filesChanged += lines.length; + + lines.forEach((line) => { + const parts = line.split('\t'); + if (parts.length >= 2) { + const added = parseInt(parts[0], 10) || 0; + const removed = parseInt(parts[1], 10) || 0; + insertions += added; + deletions += removed; + } + }); + } + } catch (err) { + // No tracked changes or error, continue + } + + try { + // Get stats for untracked files (stored in stash^3) + // First check if the third parent exists (untracked files commit) + const untrackedResult = await git.raw(['diff', '--numstat', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', `stash@{${stashIndex}}^3`]); + + if (untrackedResult) { + const lines = untrackedResult.trim().split('\n').filter((line) => line.trim()); + filesChanged += lines.length; + + lines.forEach((line) => { + const parts = line.split('\t'); + if (parts.length >= 2) { + const added = parseInt(parts[0], 10) || 0; + // Untracked files are all additions + insertions += added; + } + }); + } + } catch (err) { + // No untracked files in stash or stash^3 doesn't exist, that's fine + } + + return { filesChanged, insertions, deletions }; +}; + +/** + * List all stashes + * @param {string} gitRootPath - Path to git repository + * @returns {Promise} List of stashes with index, message, date, and diff stats + */ +const listStashes = async (gitRootPath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + try { + const stashList = await git.stashList(); + const stashEntries = stashList.all || []; + + // Fetch stats for each stash in parallel + const stashesWithStats = await Promise.all( + stashEntries.map(async (stash, index) => { + const stats = await getStashStats(git, index); + return { + index: index, + hash: stash.hash, + message: stash.message, + date: stash.date, + filesChanged: stats.filesChanged, + insertions: stats.insertions, + deletions: stats.deletions + }; + }) + ); + + return stashesWithStats; + } catch (err) { + throw err; + } +}; + +/** + * Apply a stash by index (restores changes but keeps stash) + * @param {string} gitRootPath - Path to git repository + * @param {number} stashIndex - Index of the stash to apply + * @returns {Promise} + */ +const applyStash = async (gitRootPath, stashIndex) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.stash(['apply', `stash@{${stashIndex}}`], (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); +}; + +/** + * Drop (delete) a stash by index + * @param {string} gitRootPath - Path to git repository + * @param {number} stashIndex - Index of the stash to drop + * @returns {Promise} + */ +const dropStash = async (gitRootPath, stashIndex) => { + return new Promise((resolve, reject) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + git.stash(['drop', `stash@{${stashIndex}}`], (err, result) => { + if (err) { + reject(err); + return; + } + resolve(result); + }); + }); +}; + +/** + * Get list of files in a stash (both tracked and untracked) + * @param {string} gitRootPath - Path to git repository + * @param {number} stashIndex - Index of the stash + * @returns {Promise} List of files with status + */ +const getStashFiles = async (gitRootPath, stashIndex) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + const files = []; + + try { + // Get tracked files from stash + const trackedResult = await git.raw(['stash', 'show', '--name-status', `stash@{${stashIndex}}`]); + + if (trackedResult) { + const lines = trackedResult.trim().split('\n').filter((line) => line.trim()); + for (const line of lines) { + const match = line.match(/^([AMDRC])\t(.+)$/); + if (match) { + const [, status, filePath] = match; + files.push({ + path: filePath, + status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'M' ? 'modified' : status === 'R' ? 'renamed' : 'changed', + isUntracked: false + }); + } + } + } + } catch (err) { + // No tracked files or error + } + + try { + // Get untracked files from stash^3 + const untrackedResult = await git.raw(['ls-tree', '-r', '--name-only', `stash@{${stashIndex}}^3`]); + + if (untrackedResult) { + const lines = untrackedResult.trim().split('\n').filter((line) => line.trim()); + for (const filePath of lines) { + files.push({ + path: filePath, + status: 'added', + isUntracked: true + }); + } + } + } catch (err) { + // No untracked files in stash + } + + return files; +}; + +/** + * Get diff for a specific file in a stash + * @param {string} gitRootPath - Path to git repository + * @param {number} stashIndex - Index of the stash + * @param {string} filePath - Path to the file + * @param {boolean} isUntracked - Whether the file is untracked + * @returns {Promise} Diff string + */ +const getStashFileDiff = async (gitRootPath, stashIndex, filePath, isUntracked = false) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + try { + if (isUntracked) { + // For untracked files, show the full content as a diff against empty + const content = await git.raw(['show', `stash@{${stashIndex}}^3:${filePath}`]); + // Format as a unified diff showing all lines as additions + const lines = content.split('\n'); + const diffLines = [ + `diff --git a/${filePath} b/${filePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${filePath}`, + `@@ -0,0 +1,${lines.length} @@`, + ...lines.map((line) => `+${line}`) + ]; + return diffLines.join('\n'); + } else { + // For tracked files, use git diff to compare stash against its parent + // stash@{n}^ is the parent commit, stash@{n} is the stash commit + const diff = await git.raw(['diff', `stash@{${stashIndex}}^`, `stash@{${stashIndex}}`, '--', filePath]); + return diff; + } + } catch (err) { + throw err; + } +}; + +/** + * Get file content at a specific commit + * @param {string} gitRootPath - Path to git repository + * @param {string} commitHash - Commit hash + * @param {string} filePath - Path to the file + * @returns {Promise} File content + */ +const getFileContentAtCommit = async (gitRootPath, commitHash, filePath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + try { + const content = await git.raw(['show', `${commitHash}:${filePath}`]); + return content; + } catch (err) { + // File might not exist at this commit (e.g., newly added file) + return null; + } +}; + +/** + * Check if file supports visual diff + * @param {string} filePath - Path to the file + * @returns {boolean} True if file supports visual diff + */ +const supportsVisualDiff = (filePath) => { + if (!filePath) return false; + + const fileName = filePath.split('/').pop(); + const excludedFiles = ['folder.yml', 'folder.bru', 'opencollection.yml', 'collection.bru']; + if (excludedFiles.includes(fileName)) { + return false; + } + + return filePath.endsWith('.bru') || filePath.endsWith('.yml'); +}; + +/** + * Parse content for visual diff viewer + * Uses parseRequest to get consistent BrunoItem structure for both .bru and .yml files + * @param {string} content - Raw file content + * @param {string} filePath - Path to the file + * @returns {object|null} Parsed BrunoItem or null if parsing fails + */ +const parseContentForVisualDiff = (content, filePath) => { + if (!content) return null; + try { + if (filePath?.endsWith('.bru')) { + return parseRequest(content, { format: 'bru' }); + } else if (filePath?.endsWith('.yml')) { + return parseRequest(content, { format: 'yml' }); + } + return null; + } catch (err) { + console.error('Error parsing content for visual diff:', err); + return null; + } +}; + +/** + * Get old and new file content for visual diff + * @param {string} gitRootPath - Path to git repository + * @param {string} commitHash - Commit hash + * @param {string} filePath - Path to the file + * @returns {Promise<{oldContent: string|null, newContent: string|null, oldParsed: object|null, newParsed: object|null}>} Old and new file content + */ +const getFileContentForVisualDiff = async (gitRootPath, commitHash, filePath) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + const canParseVisualDiff = supportsVisualDiff(filePath); + + try { + // Get the new content (at the commit) + let newContent = null; + try { + newContent = await git.raw(['show', `${commitHash}:${filePath}`]); + } catch (err) { + // File might be deleted in this commit + newContent = null; + } + + // Get the old content (at the parent commit) + let oldContent = null; + try { + oldContent = await git.raw(['show', `${commitHash}^:${filePath}`]); + } catch (err) { + // File might not exist in parent (newly added) + oldContent = null; + } + + // Parse content if applicable + let oldParsed = null; + let newParsed = null; + + if (canParseVisualDiff) { + oldParsed = parseContentForVisualDiff(oldContent, filePath); + newParsed = parseContentForVisualDiff(newContent, filePath); + } + + return { oldContent, newContent, oldParsed, newParsed }; + } catch (err) { + throw err; + } +}; + +/** + * Get old and new file content for visual diff (for staged/unstaged changes) + * @param {string} gitRootPath - Path to git repository + * @param {string} filePath - Path to the file (relative to git root) + * @param {string} type - Type of change: 'staged', 'unstaged', or 'renamed' + * @returns {Promise<{oldContent: string|null, newContent: string|null, oldParsed: object|null, newParsed: object|null}>} Old and new file content + */ +const getWorkingFileContentForVisualDiff = async (gitRootPath, filePath, type) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + const fullPath = path.join(gitRootPath, filePath); + const canParseVisualDiff = supportsVisualDiff(filePath); + + try { + let oldContent = null; + let newContent = null; + + if (type === 'staged') { + // For staged changes: old = HEAD, new = index (staged) + try { + oldContent = await git.raw(['show', `HEAD:${filePath}`]); + } catch (err) { + // File might be newly added + oldContent = null; + } + + try { + newContent = await git.raw(['show', `:${filePath}`]); + } catch (err) { + // File might be deleted + newContent = null; + } + } else if (type === 'unstaged') { + // For unstaged changes: old = index or HEAD, new = working directory + try { + // Try to get from index first (if there are staged changes) + oldContent = await git.raw(['show', `:${filePath}`]); + } catch (err) { + try { + // Fall back to HEAD + oldContent = await git.raw(['show', `HEAD:${filePath}`]); + } catch (err2) { + // File might be untracked + oldContent = null; + } + } + + // Read working directory content + try { + newContent = fs.readFileSync(fullPath, 'utf8'); + } catch (err) { + // File might be deleted + newContent = null; + } + } + + // Parse content if applicable + let oldParsed = null; + let newParsed = null; + + if (canParseVisualDiff) { + oldParsed = parseContentForVisualDiff(oldContent, filePath); + newParsed = parseContentForVisualDiff(newContent, filePath); + } + + return { oldContent, newContent, oldParsed, newParsed }; + } catch (err) { + throw err; + } +}; + +/** + * Get old and new file content for visual diff (for stash) + * @param {string} gitRootPath - Path to git repository + * @param {number} stashIndex - Index of the stash + * @param {string} filePath - Path to the file + * @param {boolean} isUntracked - Whether the file is untracked + * @returns {Promise<{oldContent: string|null, newContent: string|null, oldParsed: object|null, newParsed: object|null}>} Old and new file content + */ +const getStashFileContentForVisualDiff = async (gitRootPath, stashIndex, filePath, isUntracked = false) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + const canParseVisualDiff = supportsVisualDiff(filePath); + + try { + let oldContent = null; + let newContent = null; + + if (isUntracked) { + // For untracked files in stash, old is null (didn't exist) + oldContent = null; + try { + newContent = await git.raw(['show', `stash@{${stashIndex}}^3:${filePath}`]); + } catch (err) { + newContent = null; + } + } else { + // For tracked files: old = stash parent, new = stash content + try { + oldContent = await git.raw(['show', `stash@{${stashIndex}}^:${filePath}`]); + } catch (err) { + oldContent = null; + } + + try { + newContent = await git.raw(['show', `stash@{${stashIndex}}:${filePath}`]); + } catch (err) { + newContent = null; + } + } + + // Parse content if applicable + let oldParsed = null; + let newParsed = null; + + if (canParseVisualDiff) { + oldParsed = parseContentForVisualDiff(oldContent, filePath); + newParsed = parseContentForVisualDiff(newContent, filePath); + } + + return { oldContent, newContent, oldParsed, newParsed }; + } catch (err) { + throw err; + } +}; + +const getGitGraph = async (gitRootPath, branchName, limit = 50) => { + const git = getSimpleGitInstanceForPath(gitRootPath); + + try { + // Get commits with parent info, request one extra to check if there are more + const result = await git.raw([ + 'log', + '--format=%H|%P|%s|%an|%aI', + '--first-parent', + '-n', String(limit + 1), // Request one extra to check hasMore + branchName || 'HEAD' + ]); + + if (!result || !result.trim()) { + return { commits: [], branches: [], hasMore: false }; + } + + const lines = result.trim().split('\n'); + + // Check if there are more commits beyond this page + const hasMore = lines.length > limit; + const commitLines = hasMore ? lines.slice(0, limit) : lines; + + const commits = []; + const mainLineHashes = new Set(); + + // Parse main line commits + for (const line of commitLines) { + const [hash, parents, message, author, date] = line.split('|'); + const parentList = parents ? parents.split(' ').filter((p) => p) : []; + const isMerge = parentList.length > 1; + + commits.push({ + hash, + message, + author_name: author, + date, + isMerge, + parents: parentList + }); + mainLineHashes.add(hash); + } + + // Get the time range of the main line commits + // First commit is newest, last commit is oldest + const oldestMainCommitDate = commits.length > 0 ? commits[commits.length - 1].date : null; + + // For merge commits, get branch commits (only within main line time range) + const branches = []; + + for (const commit of commits) { + if (commit.isMerge && commit.parents[1]) { + try { + // Build git log command with --since to limit to main line time range + const logArgs = [ + 'log', + '--format=%H|%P|%s|%an|%aI', + '--first-parent', + '-n', '50' + ]; + + // Only include commits since the oldest main line commit + if (oldestMainCommitDate) { + logArgs.push('--since=' + oldestMainCommitDate); + } + + logArgs.push(commit.parents[1]); + + const branchResult = await git.raw(logArgs); + + if (branchResult && branchResult.trim()) { + const branchLines = branchResult.trim().split('\n'); + const branchCommits = []; + + for (const bLine of branchLines) { + const [bHash, bParents, bMessage, bAuthor, bDate] = bLine.split('|'); + + // Stop if we hit main line + if (mainLineHashes.has(bHash)) break; + + branchCommits.push({ + hash: bHash, + message: bMessage, + author_name: bAuthor, + date: bDate, + parents: bParents ? bParents.split(' ').filter((p) => p) : [] + }); + } + + if (branchCommits.length > 0) { + branches.push({ + mergeCommitHash: commit.hash, + commits: branchCommits + }); + } + } + } catch (err) { + // Ignore errors for individual branch traversal + } + } + } + + return { commits, branches, hasMore }; + } catch (err) { + console.error('Error getting git graph:', err); + return { commits: [], branches: [], hasMore: false }; + } +}; + +module.exports = { + getCollectionGitRootPath, + getCollectionGitRepoUrl, + stageChanges, + unstageChanges, + discardChanges, + commitChanges, + getChangedFilesInCollectionGit, + getCollectionGitBranches, + getDefaultGitBranch, + getCurrentGitBranch, + checkoutGitBranch, + getCollectionGitLogs, + getCollectionGitTagsWithDetails, + canPush, + pushGitChanges, + pullGitChanges, + initGit, + getCollectionGitData, + getStagedFileDiff, + getUnstagedFileDiff, + getRenamedFileDiff, + cloneGitRepository, + fetchChanges, + fetchRemotes, + fetchRemoteBranches, + checkoutRemoteGitBranch, + getGitVersion, + addRemote, + removeRemote, + getAheadBehindCount, + getAheadCount, + getBehindCount, + abortConflictResolution, + continueMerge, + getCommitFiles, + getCommitFileDiff, + getCommitCompareFiles, + getCommitCompareFileDiff, + getFileGitHistory, + getGitGraph, + createStash, + listStashes, + applyStash, + dropStash, + getStashFiles, + getStashFileDiff, + getFileContentAtCommit, + getFileContentForVisualDiff, + getWorkingFileContentForVisualDiff, + getStashFileContentForVisualDiff +}; diff --git a/tests/import/bulk-import/001-multiple-files-upload.spec.ts b/tests/import/bulk-import/001-multiple-files-upload.spec.ts new file mode 100644 index 000000000..8b2bbf4d0 --- /dev/null +++ b/tests/import/bulk-import/001-multiple-files-upload.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('Multiple Files Upload', () => { + const testDataDir = path.join(__dirname, '../test-data'); + + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Multiple files can be uploaded together', async ({ page, createTmpDir }) => { + const postmanFile = path.join(testDataDir, 'sample-postman.json'); + const insomniaFile = path.join(testDataDir, 'sample-insomnia.json'); + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByRole('dialog'); + await importModal.waitFor({ state: 'visible' }); + await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection'); + + await page.setInputFiles('input[type="file"]', [postmanFile, insomniaFile]); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the Bulk Import modal is now displayed + const bulkImportModal = page.getByRole('dialog'); + await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import'); + + // Check that the Collections count shows 2 collections in the Bulk Import modal + await expect(bulkImportModal.getByText('Collections (2)')).toBeVisible(); + + // Verify collection names are displayed + await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible(); + await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible(); + + // Select a location and import + await page.locator('#collection-location').fill(await createTmpDir('multiple-files-test')); + await bulkImportModal.getByRole('button', { name: 'Import' }).click(); + + // Wait for import to complete (summary modal shows with "Close" button) + await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible(); + + // Close the summary modal + await bulkImportModal.getByRole('button', { name: 'Close' }).click(); + await bulkImportModal.waitFor({ state: 'hidden' }); + + // Verify collections were imported successfully + await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible(); + await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible(); + }); +}); diff --git a/tests/import/bulk-import/002-all-collection-types.spec.ts b/tests/import/bulk-import/002-all-collection-types.spec.ts new file mode 100644 index 000000000..285dad9e4 --- /dev/null +++ b/tests/import/bulk-import/002-all-collection-types.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('All Collection Types Bulk Import', () => { + const testDataDir = path.join(__dirname, '../test-data'); + + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('All 4 collection types appear in bulk import', async ({ page, createTmpDir }) => { + const postmanFile = path.join(testDataDir, 'sample-postman.json'); + const insomniaFile = path.join(testDataDir, 'sample-insomnia.json'); + const brunoFile = path.join(testDataDir, 'sample-bruno.json'); + const openapiFile = path.join(testDataDir, 'sample-openapi.yaml'); + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByRole('dialog'); + await importModal.waitFor({ state: 'visible' }); + await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection'); + + await page.setInputFiles('input[type="file"]', [postmanFile, insomniaFile, brunoFile, openapiFile]); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the Bulk Import modal is displayed (no separate settings modal anymore) + const bulkImportModal = page.getByRole('dialog'); + await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import'); + + // Check that the Collections count shows 4 collections in the Bulk Import modal + await expect(bulkImportModal.getByText('Collections (4)')).toBeVisible(); + await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible(); + await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible(); + await expect(bulkImportModal.getByText('Sample Bruno Collection')).toBeVisible(); + await expect(bulkImportModal.getByText('Sample API')).toBeVisible(); + + // Verify that OpenAPI settings are visible (since one file is OpenAPI) + await expect(bulkImportModal.getByText('Folder arrangement')).toBeVisible(); + await expect(bulkImportModal.getByTestId('grouping-dropdown')).toBeVisible(); + + // Optionally change grouping to path-based + await bulkImportModal.getByTestId('grouping-dropdown').click(); + await bulkImportModal.getByTestId('grouping-option-path').click(); + + // Select a location and import + await page.locator('#collection-location').fill(await createTmpDir('all-collection-types-test')); + await bulkImportModal.getByRole('button', { name: 'Import' }).click(); + + // Wait for import to complete (summary modal shows with "Close" button) + await expect(bulkImportModal.getByRole('button', { name: 'Close' })).toBeVisible(); + + // Close the summary modal + await bulkImportModal.getByRole('button', { name: 'Close' }).click(); + await bulkImportModal.waitFor({ state: 'hidden' }); + + // Verify all collections were imported successfully + await expect(page.locator('#sidebar-collection-name').getByText('Sample Postman Collection')).toBeVisible(); + await expect(page.locator('#sidebar-collection-name').getByText('Sample Insomnia Collection')).toBeVisible(); + await expect(page.locator('#sidebar-collection-name').getByText('Sample Bruno Collection')).toBeVisible(); + await expect(page.locator('#sidebar-collection-name').getByText('Sample API')).toBeVisible(); + }); +}); diff --git a/tests/import/test-data/sample-bruno.json b/tests/import/test-data/sample-bruno.json new file mode 100644 index 000000000..47418af64 --- /dev/null +++ b/tests/import/test-data/sample-bruno.json @@ -0,0 +1,43 @@ +{ + "version": "1", + "uid": "bruno_test_collection_1", + "name": "Sample Bruno Collection", + "items": [ + { + "uid": "bruno_test_request_1", + "type": "http-request", + "name": "Get Sample Data", + "seq": 1, + "request": { + "url": "https://jsonplaceholder.typicode.com/todos/1", + "method": "GET", + "headers": [], + "params": [], + "body": { + "mode": "none" + }, + "auth": { + "mode": "none" + }, + "script": {}, + "vars": {}, + "assertions": [], + "tests": "", + "docs": "" + } + } + ], + "environments": [], + "activeEnvironmentUid": null, + "root": { + "request": { + "headers": [], + "auth": { + "mode": "none" + }, + "script": {}, + "vars": {}, + "tests": "" + } + } +} diff --git a/tests/import/test-data/sample-insomnia.json b/tests/import/test-data/sample-insomnia.json new file mode 100644 index 000000000..3c6e769d3 --- /dev/null +++ b/tests/import/test-data/sample-insomnia.json @@ -0,0 +1,41 @@ +{ + "_type": "export", + "__export_format": 4, + "__export_date": "2023-01-01T00:00:00.000Z", + "__export_source": "insomnia.desktop.app:v2023.1.0", + "resources": [ + { + "_id": "req_123", + "authentication": {}, + "body": {}, + "created": 1672531200000, + "description": "", + "headers": [], + "isPrivate": false, + "metaSortKey": -1672531200000, + "method": "GET", + "modified": 1672531200000, + "name": "Get Posts", + "parameters": [], + "parentId": "wrk_456", + "settingDisableRenderRequestBody": false, + "settingEncodeUrl": true, + "settingFollowRedirects": "global", + "settingRebuildPath": true, + "settingSendCookies": true, + "settingStoreCookies": true, + "url": "https://jsonplaceholder.typicode.com/posts", + "_type": "request" + }, + { + "_id": "wrk_456", + "created": 1672531200000, + "description": "Sample Insomnia collection for testing", + "modified": 1672531200000, + "name": "Sample Insomnia Collection", + "parentId": null, + "scope": "collection", + "_type": "workspace" + } + ] +} diff --git a/tests/import/test-data/sample-openapi.yaml b/tests/import/test-data/sample-openapi.yaml new file mode 100644 index 000000000..c3e6741f7 --- /dev/null +++ b/tests/import/test-data/sample-openapi.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.0 +info: + title: Sample API + description: A simple API for testing OpenAPI imports + version: 1.0.0 +servers: + - url: https://jsonplaceholder.typicode.com +paths: + /posts: + get: + summary: Get all posts + description: Retrieve a list of all posts + responses: + '200': + description: List of posts + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + title: + type: string + body: + type: string + userId: + type: integer + post: + summary: Create a new post + description: Create a new post + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + body: + type: string + userId: + type: integer + responses: + '201': + description: Post created successfully + /posts/{id}: + get: + summary: Get a specific post + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Post details diff --git a/tests/import/test-data/sample-postman.json b/tests/import/test-data/sample-postman.json new file mode 100644 index 000000000..525c84477 --- /dev/null +++ b/tests/import/test-data/sample-postman.json @@ -0,0 +1,44 @@ +{ + "info": { + "name": "Sample Postman Collection", + "description": "A simple collection for testing imports", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Get Users", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://jsonplaceholder.typicode.com/users", + "protocol": "https", + "host": ["jsonplaceholder", "typicode", "com"], + "path": ["users"] + } + } + }, + { + "name": "Create User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n}" + }, + "url": { + "raw": "https://jsonplaceholder.typicode.com/users", + "protocol": "https", + "host": ["jsonplaceholder", "typicode", "com"], + "path": ["users"] + } + } + } + ] +} diff --git a/tests/import/url-import/github-repository-import.spec.ts b/tests/import/url-import/github-repository-import.spec.ts new file mode 100644 index 000000000..8c516582e --- /dev/null +++ b/tests/import/url-import/github-repository-import.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('GitHub Repository URL Import', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('GitHub repository URL import', async ({ page }) => { + const githubUrl = 'https://github.com/usebruno/github-rest-api-collection'; + + // Test GitHub repository import + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByRole('dialog'); + await importModal.waitFor({ state: 'visible' }); + await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection'); + + // Select the GitHub tab + await page.getByTestId('github-tab').click(); + + // Fill in the URL input + await page.getByTestId('git-url-input').fill(githubUrl); + await page.locator('#clone-git-button').click(); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the Clone Git Repository modal is displayed + const cloneModal = page.getByRole('dialog'); + await expect(cloneModal.locator('.bruno-modal-header-title')).toContainText('Clone Git Repository'); + + // Cleanup: close any open modals using Cancel button (avoids form validation) + await page.getByRole('button', { name: 'Cancel' }).click(); + }); +}); diff --git a/tests/import/url-import/insomnia-url-import.spec.ts b/tests/import/url-import/insomnia-url-import.spec.ts new file mode 100644 index 000000000..3a3d51643 --- /dev/null +++ b/tests/import/url-import/insomnia-url-import.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, openCollection } from '../../utils/page'; + +test.describe('Insomnia URL Import', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Insomnia URL import', async ({ page, createTmpDir }) => { + const insomniaUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/insomnia/fixtures/insomnia-v5.yaml'; + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByTestId('import-collection-modal'); + await importModal.waitFor({ state: 'visible' }); + + await page.getByTestId('url-tab').click(); + await page.getByTestId('url-input').waitFor({ state: 'visible' }); + await page.getByTestId('url-input').fill(insomniaUrl); + await page.locator('#import-url-button').click(); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the collection location modal appears + const locationModal = page.getByTestId('import-collection-location-modal'); + await expect(locationModal.getByText('Test API Collection v5')).toBeVisible(); + + // Select a location and import + await page.locator('#collection-location').fill(await createTmpDir('test-api-collection-v5')); + await locationModal.getByRole('button', { name: 'Import' }).click(); + + // Verify the collection was imported successfully and configure it + await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v5')).toBeVisible(); + await openCollection(page, 'Test API Collection v5'); + + // Verify these folder names are present + await expect(page.locator('.collection-item-name').getByText('API Tests')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('Data Management')).toBeVisible(); + }); +}); diff --git a/tests/import/url-import/openapi-url-import.spec.ts b/tests/import/url-import/openapi-url-import.spec.ts new file mode 100644 index 000000000..3e19e4110 --- /dev/null +++ b/tests/import/url-import/openapi-url-import.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, openCollection } from '../../utils/page'; + +test.describe('OpenAPI URL Import', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Swagger/OpenAPI URL import', async ({ page, createTmpDir }) => { + const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json'; + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByTestId('import-collection-modal'); + await importModal.waitFor({ state: 'visible' }); + + await page.getByTestId('url-tab').click(); + await page.getByTestId('url-input').waitFor({ state: 'visible' }); + await page.getByTestId('url-input').fill(openapiUrl); + await page.locator('#import-url-button').click(); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the collection location modal appears with OpenAPI settings + const locationModal = page.getByTestId('import-collection-location-modal'); + await expect(locationModal.getByText('Swagger Petstore')).toBeVisible(); + + // Verify OpenAPI settings are available in the location modal + await expect(locationModal.getByText('Folder arrangement')).toBeVisible(); + await expect(locationModal.getByTestId('grouping-dropdown')).toBeVisible(); + + // Select a location and import with default grouping (tags) + await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore')); + await locationModal.getByRole('button', { name: 'Import' }).click(); + + // Verify the collection was imported successfully and configure it + await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible(); + await openCollection(page, 'Swagger Petstore'); + + // Verify these folder names are present (tag-based grouping) + await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible(); + }); + + test('Swagger/OpenAPI URL import with path-based grouping', async ({ page, createTmpDir }) => { + const openapiUrl = 'https://petstore.swagger.io/v2/swagger.json'; + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByTestId('import-collection-modal'); + await importModal.waitFor({ state: 'visible' }); + + await page.getByTestId('url-tab').click(); + await page.getByTestId('url-input').waitFor({ state: 'visible' }); + await page.getByTestId('url-input').fill(openapiUrl); + await page.locator('#import-url-button').click(); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the collection location modal appears with OpenAPI settings + const locationModal = page.getByTestId('import-collection-location-modal'); + await expect(locationModal.getByText('Swagger Petstore')).toBeVisible(); + + // Verify OpenAPI settings are available in the location modal + await expect(locationModal.getByText('Folder arrangement')).toBeVisible(); + + // Select path-based grouping from the dropdown + await locationModal.getByTestId('grouping-dropdown').click(); + + // Wait for dropdown options to be visible and select path-based grouping + await page.getByTestId('grouping-option-path').waitFor({ state: 'visible' }); + await page.getByTestId('grouping-option-path').click(); + + // Select a location and import with path-based grouping + await page.locator('#collection-location').fill(await createTmpDir('swagger-petstore-path')); + await locationModal.getByRole('button', { name: 'Import' }).click(); + + // Verify the collection was imported successfully and configure it + await expect(page.locator('#sidebar-collection-name').getByText('Swagger Petstore')).toBeVisible(); + await openCollection(page, 'Swagger Petstore'); + + // Verify that the collection has been imported with path-based grouping + // Should have folders based on URL paths like 'pet', 'store', 'user' + await expect(page.locator('.collection-item-name').getByText('pet')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('store')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('user')).toBeVisible(); + + // Expand the pet folder to check for nested path structure + await page.locator('.collection-item-name').getByText('pet').click(); + + // Verify that the pet folder contains path-based subfolders like '{petId}' + await expect(page.locator('.collection-item-name').getByText('{petId}')).toBeVisible(); + }); +}); diff --git a/tests/import/url-import/postman-url-import.spec.ts b/tests/import/url-import/postman-url-import.spec.ts new file mode 100644 index 000000000..35d8566f0 --- /dev/null +++ b/tests/import/url-import/postman-url-import.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, openCollection } from '../../utils/page'; + +test.describe('Postman URL Import', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Postman URL import', async ({ page, createTmpDir }) => { + const postmanUrl = 'https://raw.githubusercontent.com/usebruno/bruno/refs/heads/main/tests/import/postman/fixtures/postman-v21.json'; + + await page.getByTestId('collections-header-add-menu').click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByTestId('import-collection-modal'); + await importModal.waitFor({ state: 'visible' }); + + await page.getByTestId('url-tab').click(); + await page.getByTestId('url-input').waitFor({ state: 'visible' }); + await page.getByTestId('url-input').fill(postmanUrl); + await page.locator('#import-url-button').click(); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the collection location modal appears + const locationModal = page.getByTestId('import-collection-location-modal'); + await expect(locationModal.getByText('Postman v2.1 Collection')).toBeVisible(); + + // Select a location and import + await page.locator('#collection-location').fill(await createTmpDir('postman-v21-collection')); + await locationModal.getByRole('button', { name: 'Import' }).click(); + + // Verify the collection was imported successfully and configure it + await expect(page.locator('#sidebar-collection-name').getByText('Postman v2.1 Collection')).toBeVisible(); + await openCollection(page, 'Postman v2.1 Collection'); + + // Verify these folder names are present + await expect(page.locator('.collection-item-name').getByText('Get Users')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('Create User')).toBeVisible(); + }); +});