diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index b0421424c45..061528e11aa 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -6,7 +6,7 @@ */ import React, { createRef } from 'react'; -import { isEqual, escapeRegExp } from 'lodash'; +import { isEqual } from 'lodash'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; @@ -17,6 +17,14 @@ import { getAllVariables } from 'utils/collections'; import { setupLinkAware } from 'utils/codemirror/linkAware'; import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors'; import CodeMirrorSearch from 'components/CodeMirrorSearch/index'; +import { + applyEditorState, + captureEditorState, + getDocKey, + readPersistedEditorState, + writePersistedEditorState +} from './state-persistence'; +import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider'; const CodeMirror = require('codemirror'); window.jsonlint = jsonlint; @@ -24,7 +32,7 @@ window.JSHINT = JSHINT; const TAB_SIZE = 2; -export default class CodeEditor extends React.Component { +class CodeEditor extends React.Component { constructor(props) { super(props); @@ -48,6 +56,12 @@ export default class CodeEditor extends React.Component { }; } + // Thin wrapper around the pure getDocKey helper from state-persistence.js. + // Kept on the class so the rest of the lifecycle code reads naturally. + _getDocKey() { + return getDocKey(this.props); + } + componentDidMount() { const variables = getAllVariables(this.props.collection, this.props.item); const runShortcut = () => { @@ -184,6 +198,19 @@ export default class CodeEditor extends React.Component { }); if (editor) { + // CM5 was constructed with props.value, so the editor already shows the + // right content. Read this tab's previously persisted view state from + // localStorage and apply it on top — restores folds, cursor, selection, + // undo history, and scroll position. + const docKey = getDocKey(this.props); + this._currentDocKey = docKey; + this.cachedValue = editor.getValue(); + applyEditorState( + editor, + readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }), + this.cachedValue + ); + editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false); editor.on('change', this._onEdit); editor.scrollTo(null, this.props.initialScroll); @@ -236,11 +263,52 @@ export default class CodeEditor extends React.Component { this.editor.options.jump.schema = this.props.schema; CodeMirror.signal(this.editor, 'change', this.editor); } - if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { - const cursor = this.editor.getCursor(); - this.cachedValue = String(this?.props?.value ?? ''); - this.editor.setValue(String(this.props.value) || ''); - this.editor.setCursor(cursor); + if (this.editor) { + // Two distinct update paths: + // 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state + // 2. Same doc, value changed → external content update → setValue (view state resets) + const newDocKey = getDocKey(this.props); + const docKeyChanged = newDocKey !== this._currentDocKey; + + if (docKeyChanged) { + // Path 1 — tab switch. + // Snapshot the outgoing tab's view state to localStorage so a future + // visit can restore it. Then setValue the incoming content and apply + // any view state previously persisted for the incoming tab. + if (this._currentDocKey) { + writePersistedEditorState({ + scope: this.props.persistenceScope, + key: this._currentDocKey, + state: captureEditorState(this.editor) + }); + } + this.cachedValue = String(this?.props?.value ?? ''); + this.editor.setValue(String(this.props.value) || ''); + this._currentDocKey = newDocKey; + applyEditorState( + this.editor, + readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }), + this.cachedValue + ); + // setValue resets the editor's mode-overlay state — re-apply the + // brunovariables overlay and re-evaluate lint config for the new content. + this.addOverlay(); + this.editor.setOption( + 'lint', + this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false + ); + } else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) { + // Path 2 — same tab, new external value (e.g. a fresh response arrived + // while this tab was active). Update content; view state resets because + // line positions no longer correspond to anything. Invalidate the + // persisted snapshot too, since the saved cursor/folds/history reflect + // the prior content. + const cursor = this.editor.getCursor(); + this.cachedValue = String(this?.props?.value ?? ''); + this.editor.setValue(String(this.props.value) || ''); + this.editor.setCursor(cursor); + writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null }); + } } if (this.editor) { @@ -289,6 +357,17 @@ export default class CodeEditor extends React.Component { this.props.onScroll(this._lastScrollTop); } + // Snapshot view state to localStorage before tearing down the editor so + // the next mount of a CodeEditor with this docKey can restore folds, + // cursor, selection, undo history, and scroll position. + if (this._currentDocKey) { + writePersistedEditorState({ + scope: this.props.persistenceScope, + key: this._currentDocKey, + state: captureEditorState(this.editor) + }); + } + this.editor?._destroyLinkAware?.(); this.editor.off('change', this._onEdit); @@ -355,3 +434,12 @@ export default class CodeEditor extends React.Component { } }; } + +const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => { + const persistenceScope = usePersistenceScope(); + return ; +}); + +CodeEditorWithPersistenceScope.displayName = 'CodeEditor'; + +export default CodeEditorWithPersistenceScope; diff --git a/packages/bruno-app/src/components/CodeEditor/state-persistence.js b/packages/bruno-app/src/components/CodeEditor/state-persistence.js new file mode 100644 index 00000000000..de071b43992 --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/state-persistence.js @@ -0,0 +1,118 @@ +/* + * CodeEditor view-state persistence — extracted for testability. + * + * Why this exists: + * Every tab switch causes CodeMirror's setValue() to wipe folds, cursor, + * selection, undo history, and scroll position. To preserve them, we serialize + * the relevant pieces to localStorage under a stable key for each editor and + * re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable + * representation of its undo stack via getHistory()/setHistory(), which is what + * makes Cmd-Z continue working across switches. + * + * Note: we deliberately do NOT persist the content itself — the canonical value + * lives in Redux (props.value). We only persist the editor's "view" state on + * top of that content. If content has drifted between save and restore, fold + * positions are applied leniently (foldCode silently no-ops on invalid lines) + * and history is skipped to avoid an inconsistent undo stack. + */ + +export const STORAGE_PREFIX = 'persisted::'; +export const DEFAULT_PERSISTENCE_SCOPE = 'global'; +export const STORAGE_SEGMENT = 'codeeditor'; + +export const getScopedStorageKey = (scope, key) => { + const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE; + return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`; +}; + +// Identifies which Doc state belongs to a given CodeEditor instance. +// +// Callers can pass an explicit `docKey` prop when the auto-derived key would +// collide — e.g. Pre-Request vs Post-Response script editors share the same +// item/mode/readOnly and need an extra disambiguator. +// +// Auto-derived parts: +// id — distinguishes different tabs (requests or collections) +// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script) +// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match +export const getDocKey = (props) => { + if (props.docKey) return props.docKey; + const id = props.item?.uid || props.collection?.uid || 'default'; + const mode = props.mode || 'default'; + const readOnly = props.readOnly ? 'ro' : 'rw'; + return `${id}:${mode}:${readOnly}`; +}; + +export const readPersistedEditorState = ({ scope, key }) => { + try { + const raw = localStorage.getItem(getScopedStorageKey(scope, key)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +}; + +export const writePersistedEditorState = ({ scope, key, state }) => { + try { + const storageKey = getScopedStorageKey(scope, key); + if (state == null) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, JSON.stringify(state)); + } + } catch { + // localStorage may be unavailable or full (Chromium ~10 MB cap). Editor + // state is non-critical — content lives in Redux — so silently ignore. + } +}; + +export const captureEditorState = (editor) => { + if (!editor) return null; + const doc = editor.getDoc(); + const folds = editor + .getAllMarks() + .filter((m) => m.__isFold) + .map((m) => m.find()) + .filter(Boolean) + .map((range) => range.from); + return { + contentLength: doc.getValue().length, + cursor: doc.getCursor(), + selections: doc.listSelections(), + history: doc.getHistory(), + folds, + scrollY: editor.getScrollInfo().top + }; +}; + +export const applyEditorState = (editor, state, currentContent) => { + if (!editor || !state) return; + const doc = editor.getDoc(); + const contentMatches = state.contentLength === (currentContent || '').length; + + // History/cursor/selection only make sense if content didn't drift — applying + // a stale undo stack to different content would let Cmd-Z replay edits that + // no longer correspond to anything visible. + if (contentMatches) { + if (state.history) { + try { doc.setHistory(state.history); } catch {} + } + if (state.cursor) { + try { doc.setCursor(state.cursor); } catch {} + } + if (state.selections && state.selections.length) { + try { doc.setSelections(state.selections); } catch {} + } + } + // Folds are cheap and lenient — try them either way. + if (state.folds && state.folds.length) { + editor.operation(() => { + state.folds.forEach((from) => { + try { editor.foldCode(from); } catch {} + }); + }); + } + if (state.scrollY != null) { + try { editor.scrollTo(null, state.scrollY); } catch {} + } +}; diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index 2a8fdd6a11d..260f0135e88 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -111,6 +111,7 @@ const Script = ({ collection }) => { { { { { { { { { props.theme.sidebar.muted}; + opacity: 0.7; + + .git-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${(props) => props.theme.sidebar.muted}; + } + + .git-collection-name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-left: 6px; + } + + .collection-actions { + visibility: hidden; + } + + &:hover, + &:focus-within { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + opacity: 0.9; + + .collection-actions { + visibility: visible; + background-color: transparent !important; + } + } + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js new file mode 100644 index 00000000000..dcba9f2535f --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js @@ -0,0 +1,105 @@ +import React, { useRef, useState } from 'react'; +import { IconBrandGit, IconCopy, IconDots, IconUnlink } from '@tabler/icons'; +import toast from 'react-hot-toast'; +import ActionIcon from 'ui/ActionIcon'; +import MenuDropdown from 'ui/MenuDropdown'; +import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import CloneGitRepository from 'components/Sidebar/CloneGitRespository'; +import RemoveGitRemote from 'components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote'; +import StyledWrapper from './StyledWrapper'; + +const GitRemoteCollectionRow = ({ entry }) => { + const { dropdownContainerRef } = useSidebarAccordion(); + const menuDropdownRef = useRef(null); + const [showCloneModal, setShowCloneModal] = useState(false); + const [showRemoveGitModal, setShowRemoveGitModal] = useState(false); + + const openCloneModal = () => setShowCloneModal(true); + const closeCloneModal = () => setShowCloneModal(false); + + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(entry.remote); + toast.success('Git URL copied'); + } catch (e) { + toast.error('Failed to copy URL'); + } + }; + + const handleRightClick = (event) => { + event.preventDefault(); + menuDropdownRef.current?.show(); + }; + + const menuItems = [ + { + id: 'clone-git', + leftSection: IconBrandGit, + label: 'Clone from Git', + onClick: openCloneModal + }, + { + id: 'copy-url', + leftSection: IconCopy, + label: 'Copy Git URL', + onClick: handleCopyUrl + }, + { + id: 'remove-git-remote', + leftSection: IconUnlink, + label: 'Remove Git Remote', + onClick: () => setShowRemoveGitModal(true) + } + ]; + + return ( + + {showCloneModal && ( + + )} + {showRemoveGitModal && ( + setShowRemoveGitModal(false)} + /> + )} +
+
+ +
{entry.name}
+
+
+
e.stopPropagation()}> + + + + + +
+
+
+
+ ); +}; + +export default GitRemoteCollectionRow; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index f0ec52c6c75..0522c232790 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,6 +1,7 @@ import React, { useState, useMemo } from 'react'; import { useSelector } from 'react-redux'; import Collection from './Collection'; +import GitRemoteCollectionRow from './GitRemoteCollectionRow'; import StyledWrapper from './StyledWrapper'; import CreateOrOpenCollection from './CreateOrOpenCollection'; import CollectionSearch from './CollectionSearch/index'; @@ -14,19 +15,35 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default'); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; - const workspaceCollections = useMemo(() => { - if (!activeWorkspace) return []; + // Build the sidebar list in workspace.yml order. Each entry is either a fully + // loaded collection (rendered via ) or, for non-default workspaces, + // a "ghost" git-backed entry whose local folder is missing (rendered via + // so the user can click to clone it). + const sidebarEntries = useMemo(() => { + if (!activeWorkspace?.collections?.length) return []; - return collections.filter((c) => { - if (isScratchCollection(c, workspaces)) { - return false; + const loadedByPath = new Map(); + for (const c of collections) { + if (isScratchCollection(c, workspaces)) continue; + if (c.pathname) loadedByPath.set(normalizePath(c.pathname), c); + } + + const entries = []; + for (const wc of activeWorkspace.collections) { + if (!wc.path) continue; + const loaded = loadedByPath.get(normalizePath(wc.path)); + if (loaded) { + entries.push({ kind: 'loaded', collection: loaded, key: loaded.uid }); + } else if (wc.remote && !isDefaultWorkspace) { + entries.push({ kind: 'ghost', entry: wc, key: `ghost:${wc.path}` }); } - return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)); - }); - }, [activeWorkspace, collections, workspaces]); + } + return entries; + }, [activeWorkspace, collections, workspaces, isDefaultWorkspace]); - if (!workspaceCollections || !workspaceCollections.length) { + if (!sidebarEntries.length) { return ( {isCreatingCollection && ( @@ -55,13 +72,12 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis onOpenAdvanced={onOpenAdvancedCreate} /> )} - {workspaceCollections && workspaceCollections.length - ? workspaceCollections.map((c) => { - return ( - - ); - }) - : null} + {sidebarEntries.map((entry) => { + if (entry.kind === 'loaded') { + return ; + } + return ; + })} ); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js new file mode 100644 index 00000000000..4d0d38f9481 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js @@ -0,0 +1,87 @@ +import React, { useRef, useEffect } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import { isGitRepositoryUrl } from 'utils/git'; +import { connectCollectionToGit } from 'providers/ReduxStore/slices/workspaces/actions'; + +const ConnectGitRemote = ({ collectionPath, collectionName, initialUrl = '', onClose }) => { + const dispatch = useDispatch(); + const inputRef = useRef(); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + remoteUrl: initialUrl + }, + validationSchema: Yup.object({ + remoteUrl: Yup.string() + .trim() + .required('Git remote URL is required') + .test('is-git-url', 'Enter a valid Git URL', (value) => isGitRepositoryUrl(value)) + }), + onSubmit: (values) => { + dispatch( + connectCollectionToGit({ + workspaceUid: activeWorkspaceUid, + collectionPath, + remoteUrl: values.remoteUrl.trim() + }) + ) + .then(() => { + toast.success('Git remote connected'); + onClose(); + }) + .catch(() => { + // toast already handled in the thunk + }); + } + }); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const title = initialUrl ? 'Update Git Remote' : 'Connect to Git'; + const confirmText = initialUrl ? 'Update' : 'Connect'; + + return ( + formik.handleSubmit()} handleCancel={onClose}> +
e.preventDefault()}> + {collectionName ? ( +
+ Linking {collectionName} to a remote Git repository. + The URL is stored in workspace.yml; local files are not changed. +
+ ) : null} +
+ + + {formik.touched.remoteUrl && formik.errors.remoteUrl ? ( +
{formik.errors.remoteUrl}
+ ) : null} +
+
+
+ ); +}; + +export default ConnectGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js new file mode 100644 index 00000000000..1a0fb1ff173 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import { disconnectCollectionFromGit } from 'providers/ReduxStore/slices/workspaces/actions'; + +const RemoveGitRemote = ({ collectionPath, collectionName, remoteUrl, onClose }) => { + const dispatch = useDispatch(); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + + const handleConfirm = () => { + dispatch( + disconnectCollectionFromGit({ + workspaceUid: activeWorkspaceUid, + collectionPath + }) + ) + .then(() => { + toast.success('Git remote removed'); + onClose(); + }) + .catch(() => { + // toast already handled in the thunk + }); + }; + + return ( + +
+

+ Disconnect {collectionName} from its Git remote? +

+ {remoteUrl ? ( +

{remoteUrl}

+ ) : null} +

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

+
+
+ ); +}; + +export default RemoveGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js index ba55f5a7730..3211de1a2a0 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js @@ -84,6 +84,28 @@ const StyledWrapper = styled.div` text-overflow: ellipsis; } + .collection-remote { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + font-family: monospace; + white-space: nowrap; + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + } + + svg { + flex-shrink: 0; + opacity: 0.85; + } + } + .collection-menu { flex-shrink: 0; color: ${(props) => props.theme.colors.text.muted}; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js index 505bffb73cd..51253c498d3 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -1,6 +1,17 @@ import React, { useState, useMemo, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons'; +import { + IconBox, + IconTrash, + IconEdit, + IconShare, + IconDots, + IconX, + IconFolder, + IconBrandGit, + IconUnlink, + IconCopy +} from '@tabler/icons'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions'; import { getRevealInFolderLabel } from 'utils/common/platform'; @@ -11,6 +22,9 @@ import RemoveCollection from 'components/Sidebar/Collections/Collection/RemoveCo import DeleteCollection from 'components/Sidebar/Collections/Collection/DeleteCollection'; import ShareCollection from 'components/ShareCollection'; import Dropdown from 'components/Dropdown'; +import StatusBadge from 'ui/StatusBadge'; +import ConnectGitRemote from './ConnectGitRemote'; +import RemoveGitRemote from './RemoveGitRemote'; import StyledWrapper from './StyledWrapper'; const CollectionsList = ({ workspace }) => { @@ -23,6 +37,11 @@ const CollectionsList = ({ workspace }) => { const [deleteCollectionModalOpen, setDeleteCollectionModalOpen] = useState(false); const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false); const [selectedCollectionUid, setSelectedCollectionUid] = useState(null); + const [gitTarget, setGitTarget] = useState(null); + const [showConnectGitModal, setShowConnectGitModal] = useState(false); + const [showRemoveGitModal, setShowRemoveGitModal] = useState(false); + + const isDefaultWorkspace = workspace?.type === 'default'; const workspaceCollections = useMemo(() => { if (!workspace.collections || workspace.collections.length === 0) { @@ -162,6 +181,47 @@ const CollectionsList = ({ workspace }) => { }); }; + const handleConnectGit = (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + if (collection.isLoaded === false) { + toast.error('Cannot connect a Git remote to a collection that is not present locally'); + return; + } + setGitTarget({ + path: collection.pathname, + name: collection.name, + remoteUrl: collection.gitRemoteUrl || '' + }); + setShowConnectGitModal(true); + }; + + const handleRemoveGit = (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + setGitTarget({ + path: collection.pathname, + name: collection.name, + remoteUrl: collection.gitRemoteUrl || '' + }); + setShowRemoveGitModal(true); + }; + + const handleCopyGitUrl = async (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + if (!collection.gitRemoteUrl) return; + try { + await navigator.clipboard.writeText(collection.gitRemoteUrl); + toast.success('Git URL copied'); + } catch (e) { + toast.error('Failed to copy URL'); + } + }; + + const closeGitModals = () => { + setShowConnectGitModal(false); + setShowRemoveGitModal(false); + setGitTarget(null); + }; + return ( {renameCollectionModalOpen && selectedCollectionUid && ( @@ -205,6 +265,24 @@ const CollectionsList = ({ workspace }) => { /> )} + {showConnectGitModal && gitTarget && ( + + )} + + {showRemoveGitModal && gitTarget && ( + + )} +
{workspaceCollections.length === 0 ? (
@@ -225,8 +303,26 @@ const CollectionsList = ({ workspace }) => {
{collection.name}
+ {!isDefaultWorkspace && collection.isGitBacked && ( + } + > + Git + + )} + {!isDefaultWorkspace && collection.isLoaded === false && ( + Not cloned + )}
{collection.pathname}
+ {!isDefaultWorkspace && collection.isGitBacked && collection.gitRemoteUrl && ( +
+ + {collection.gitRemoteUrl} +
+ )}
{ {getRevealInFolderLabel()}
+ {!isDefaultWorkspace && ( + <> + {collection.isGitBacked && ( +
{ + e.stopPropagation(); + handleCopyGitUrl(collection); + }} + > + + Copy Git URL +
+ )} + {!collection.isGitBacked && collection.isLoaded !== false && ( +
{ + e.stopPropagation(); + handleConnectGit(collection); + }} + > + + Connect to Git +
+ )} + {collection.isGitBacked && ( +
{ + e.stopPropagation(); + handleRemoveGit(collection); + }} + > + + Remove Git Remote +
+ )} + + )}
{ diff --git a/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx b/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx index feb0b91e554..ea9ed89155a 100644 --- a/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx +++ b/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx @@ -1,5 +1,4 @@ - -import * as React from "react" +import * as React from 'react'; import { ReactNode } from 'react'; import { createContext, useContext } from 'react'; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index e9ab09820f3..88ea6f28a5b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -230,6 +230,51 @@ export const openWorkspaceDialog = () => { }; }; +export const connectCollectionToGit = ({ workspaceUid, collectionPath, remoteUrl }) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke( + 'renderer:connect-collection-to-git', + workspace.pathname, + collectionPath, + remoteUrl + ); + + return true; + } catch (error) { + toast.error(error.message || 'Failed to connect Git remote'); + throw error; + } + }; +}; + +export const disconnectCollectionFromGit = ({ workspaceUid, collectionPath }) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke( + 'renderer:disconnect-collection-from-git', + workspace.pathname, + collectionPath + ); + + return true; + } catch (error) { + toast.error(error.message || 'Failed to remove Git remote'); + throw error; + } + }; +}; + export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath, options = {}) => { return async (dispatch, getState) => { try { @@ -283,7 +328,8 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => { }; try { - await dispatch(loadWorkspaceCollections(workspace.uid)); + const shouldRefreshCollections = workspace.collections?.some((collection) => collection.notFoundLocally); + await dispatch(loadWorkspaceCollections(workspace.uid, shouldRefreshCollections)); const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid)); if (updatedWorkspace?.collections?.length > 0) { @@ -292,6 +338,7 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => { ); const collectionPaths = updatedWorkspace.collections + .filter((wc) => !wc.notFoundLocally) .map((wc) => wc.path) .filter((p) => p && !alreadyOpenCollections.includes(normalizePath(p))); @@ -531,6 +578,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa if (workspace?.collections?.length > 0) { const newCollectionPaths = workspace.collections + .filter((workspaceCollection) => !workspaceCollection.notFoundLocally) .map((workspaceCollection) => workspaceCollection.path) .filter((collectionPath) => collectionPath && !openCollections.includes(normalizePath(collectionPath))); diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index ff89d01b24d..89e79fda132 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -132,7 +132,7 @@ const containsSecretVariableReferences = (rawValue, collection, item) => { return false; }; -const getCopyButton = (variableValue, onCopyCallback) => { +const getCopyButton = (getVariableValue, onCopyCallback) => { const copyButton = document.createElement('button'); copyButton.className = 'copy-button'; @@ -150,8 +150,11 @@ const getCopyButton = (variableValue, onCopyCallback) => { return; } + // Resolve the latest value at click time so edits/saves are reflected. + const valueToCopy = typeof getVariableValue === 'function' ? getVariableValue() : getVariableValue; + navigator.clipboard - .writeText(variableValue) + .writeText(valueToCopy ?? '') .then(() => { isCopied = true; copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; @@ -415,6 +418,11 @@ export const renderVarInfo = (token, options) => { // Store original value for comparison and track editing state let originalValue = rawValue; let isEditing = false; + // Latest resolved value and mask state used by the copy button, eye toggle, and + // error-revert path. Updated after each successful save so subsequent redraws + // reflect the saved state. `??` preserves falsy-but-valid values like 0 / false. + let currentInterpolatedValue = variableValue ?? ''; + let currentShouldMaskValue = shouldMaskValue; cmEditor.setOption('extraKeys', { 'Enter': (cm) => { @@ -461,8 +469,8 @@ export const renderVarInfo = (token, options) => { // Update icon toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG; - // Update display mode - updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed); + // Update display mode using live state so post-save values/masking are reflected. + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); // Update editor mode if (maskedEditor) { @@ -480,8 +488,9 @@ export const renderVarInfo = (token, options) => { iconsContainer.appendChild(toggleButton); } - // Copy button (copy actual value, not masked) - const copyButton = getCopyButton(variableValue || '', () => { + // Copy button (copy actual value, not masked). Uses a getter so it always + // reflects the latest saved value, not the value captured at popup creation. + const copyButton = getCopyButton(() => currentInterpolatedValue, () => { // Refocus the editor if it's currently in edit mode if (isEditing) { setTimeout(() => { @@ -500,23 +509,30 @@ export const renderVarInfo = (token, options) => { if (isEditing) return; isEditing = true; - valueDisplay.style.display = 'none'; + + // Stage editor off-visual first to avoid a visible resize/text flash. editorContainer.style.display = 'block'; + editorContainer.style.visibility = 'hidden'; // Focus the editor and ensure proper sizing - setTimeout(() => { + requestAnimationFrame(() => { cmEditor.refresh(); + + // Adjust height based on content before revealing editor + const sizer = cmEditor.getWrapperElement().querySelector('.CodeMirror-sizer'); + const contentHeight = sizer ? sizer.clientHeight : cmEditor.getScrollInfo().height; + editorContainer.style.height = `${calculateEditorHeight(contentHeight)}rem`; + + // Swap display only after editor layout is ready + valueDisplay.style.display = 'none'; + editorContainer.style.visibility = 'visible'; cmEditor.focus(); // Set cursor to end of content const lineCount = cmEditor.lineCount(); const lastLine = cmEditor.getLine(lineCount - 1); cmEditor.setCursor(lineCount - 1, lastLine ? lastLine.length : 0); - - // Adjust height based on content - const contentHeight = cmEditor.getScrollInfo().height; - editorContainer.style.height = `${calculateEditorHeight(contentHeight)}rem`; - }, 0); + }); }); // Save on blur and return to display mode @@ -525,6 +541,7 @@ export const renderVarInfo = (token, options) => { // Switch back to display mode editorContainer.style.display = 'none'; + editorContainer.style.visibility = 'visible'; editorContainer.style.height = `${EDITOR_MIN_HEIGHT}rem`; // Reset to minimum height valueDisplay.style.display = 'block'; isEditing = false; @@ -547,18 +564,22 @@ export const renderVarInfo = (token, options) => { } } - // Re-interpolate the new value to show the resolved value in display + // Re-interpolate the new value to show the resolved value in display. + // Use `??` so falsy-but-valid values (0 / false / '') survive the assignment. const interpolatedValue = interpolate(newValue, allVariables); - // Check if the NEW value contains secret references + currentInterpolatedValue = interpolatedValue ?? ''; + // Check if the NEW value contains secret references and update live mask state const newHasSecretRefs = containsSecretVariableReferences(newValue, collection, item); - const newShouldMask = isSecret || newHasSecretRefs; - updateValueDisplay(valueDisplay, interpolatedValue, newShouldMask, isMasked, isRevealed); + currentShouldMaskValue = isSecret || newHasSecretRefs; + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); }) .catch((err) => { console.error('Failed to update variable:', err); - // Revert on error + // Revert on error to the last good state — currentInterpolatedValue and + // currentShouldMaskValue still hold pre-attempt values since the success + // block above never ran. cmEditor.setValue(originalValue); - updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed); + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); }); } }); @@ -810,8 +831,10 @@ if (!SERVER_RENDERED) { } function showPopup(cm, box, brunoVarInfo) { - // If there's already an active popup, remove it first - if (activePopup && activePopup.parentNode) { + // If there's already an active popup, hide it first to ensure listeners are cleaned up + if (activePopup && typeof activePopup._hidePopup === 'function') { + activePopup._hidePopup({ immediate: true }); + } else if (activePopup && activePopup.parentNode) { activePopup.parentNode.removeChild(activePopup); activePopup = null; } @@ -865,20 +888,49 @@ if (!SERVER_RENDERED) { popup.style.left = `${leftPos / 16}rem`; let popupTimeout; + let isPinned = false; + let isHidden = false; const onMouseOverPopup = function () { clearTimeout(popupTimeout); }; const onMouseOut = function () { + if (isPinned) { + return; + } clearTimeout(popupTimeout); popupTimeout = setTimeout(hidePopup, 500); }; - const hidePopup = function () { + const onPopupClick = function (e) { + if (!popup.contains(e.target)) { + return; + } + isPinned = true; + clearTimeout(popupTimeout); + }; + + const onDocumentClick = function (e) { + if (!popup.contains(e.target)) { + isPinned = false; + hidePopup(); + } + }; + + const hidePopup = function (options = {}) { + if (isHidden) { + return; + } + isHidden = true; + + const { immediate = false } = options; + clearTimeout(popupTimeout); CodeMirror.off(popup, 'mouseover', onMouseOverPopup); CodeMirror.off(popup, 'mouseout', onMouseOut); + CodeMirror.off(popup, 'click', onPopupClick); CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut); + CodeMirror.off(document, 'click', onDocumentClick); CodeMirror.off(cm, 'change', onEditorChange); // Cleanup CodeMirror and MaskedEditor instances @@ -908,6 +960,13 @@ if (!SERVER_RENDERED) { activePopup = null; } + if (immediate) { + if (popup.parentNode) { + popup.parentNode.removeChild(popup); + } + return; + } + if (popup.style.opacity) { popup.style.opacity = 0; setTimeout(function () { @@ -922,12 +981,19 @@ if (!SERVER_RENDERED) { // Hide popup when user types in the main editor const onEditorChange = function () { - hidePopup(); + if (!isPinned) { + hidePopup(); + } }; + // Allow replacing existing popup with full cleanup + popup._hidePopup = hidePopup; + CodeMirror.on(popup, 'mouseover', onMouseOverPopup); CodeMirror.on(popup, 'mouseout', onMouseOut); + CodeMirror.on(popup, 'click', onPopupClick); CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut); + CodeMirror.on(document, 'click', onDocumentClick); CodeMirror.on(cm, 'change', onEditorChange); } } diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 1b5a2551c9a..222b2611a3a 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -21,32 +21,23 @@ const { updateWorkspaceDocs, addCollectionToWorkspace, removeCollectionFromWorkspace, + setCollectionGitRemote, + clearCollectionGitRemote, reorderWorkspaceCollections, getWorkspaceCollections, + resolveAndFilterWorkspaceCollections, normalizeCollectionEntry, validateWorkspacePath, validateWorkspaceDirectory, getWorkspaceUid } = require('../utils/workspace-config'); -const { isValidCollectionDirectory } = require('../utils/filesystem'); - const DEFAULT_WORKSPACE_NAME = 'My Workspace'; const prepareWorkspaceConfigForClient = (workspaceConfig, workspacePath, isDefault) => { - const collections = workspaceConfig.collections || []; - const filteredCollections = collections - .map((collection) => { - if (collection.path && !path.isAbsolute(collection.path)) { - return { ...collection, path: path.resolve(workspacePath, collection.path) }; - } - return collection; - }) - .filter((collection) => collection.path && isValidCollectionDirectory(collection.path)); - const config = { ...workspaceConfig, - collections: filteredCollections + collections: resolveAndFilterWorkspaceCollections(workspacePath, workspaceConfig.collections) }; if (isDefault) { @@ -582,6 +573,42 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { } }); + const broadcastWorkspaceConfig = (workspacePath, config) => { + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; + const configForClient = prepareWorkspaceConfigForClient(config, workspacePath, isDefault); + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, configForClient); + }; + + ipcMain.handle('renderer:connect-collection-to-git', async (event, workspacePath, collectionPath, remoteUrl) => { + try { + if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') { + throw new Error('A Git remote URL is required'); + } + + const trimmedUrl = remoteUrl.trim(); + if (!/^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/.test(trimmedUrl)) { + throw new Error('Invalid Git remote URL'); + } + + const updatedConfig = await setCollectionGitRemote(workspacePath, collectionPath, trimmedUrl); + broadcastWorkspaceConfig(workspacePath, updatedConfig); + return true; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:disconnect-collection-from-git', async (event, workspacePath, collectionPath) => { + try { + const updatedConfig = await clearCollectionGitRemote(workspacePath, collectionPath); + broadcastWorkspaceConfig(workspacePath, updatedConfig); + return true; + } catch (error) { + throw error; + } + }); + ipcMain.handle('renderer:get-collection-workspaces', async (event, collectionPath) => { try { const workspacePaths = lastOpenedWorkspaces.getAll(); diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index b02c49777b2..1a29e0d60f4 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -10,6 +10,8 @@ const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p); const WORKSPACE_TYPE = 'workspace'; const OPENCOLLECTION_VERSION = '1.0.0'; +const GITIGNORE_MANAGED_BLOCK_START = '# Bruno managed collection remotes'; +const GITIGNORE_MANAGED_BLOCK_END = '# End Bruno managed collection remotes'; const quoteYamlValue = (value) => { if (typeof value !== 'string') { @@ -360,6 +362,128 @@ const addCollectionToWorkspace = async (workspacePath, collection) => { }); }; +const getCollectionGitignoreEntry = (workspacePath, collectionPath) => { + const absolute = path.isAbsolute(collectionPath) + ? collectionPath + : path.resolve(workspacePath, collectionPath); + const relative = path.relative(workspacePath, absolute); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; + return posixifyPath(relative).replace(/\/+$/, '') + '/'; +}; + +const findGitignoreManagedBlock = (lines) => { + const start = lines.findIndex((line) => line.trim() === GITIGNORE_MANAGED_BLOCK_START); + if (start === -1) return null; + + const end = lines.findIndex((line, index) => index > start && line.trim() === GITIGNORE_MANAGED_BLOCK_END); + if (end === -1) return null; + + return { start, end }; +}; + +const addCollectionToWorkspaceGitignore = async (workspacePath, collectionPath) => { + const entry = getCollectionGitignoreEntry(workspacePath, collectionPath); + if (!entry) return; + + const gitignorePath = path.join(workspacePath, '.gitignore'); + const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : ''; + const lines = existing.split('\n'); + + if (lines.some((line) => line.trim() === entry)) return; + + const managedBlock = findGitignoreManagedBlock(lines); + if (managedBlock) { + const updated = [...lines]; + updated.splice(managedBlock.end, 0, entry); + await writeFile(gitignorePath, updated.join('\n')); + return; + } + + const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : existing + '\n'; + await writeFile(gitignorePath, `${prefix}${GITIGNORE_MANAGED_BLOCK_START}\n${entry}\n${GITIGNORE_MANAGED_BLOCK_END}\n`); +}; + +const removeCollectionFromWorkspaceGitignore = async (workspacePath, collectionPath) => { + const entry = getCollectionGitignoreEntry(workspacePath, collectionPath); + if (!entry) return; + + const gitignorePath = path.join(workspacePath, '.gitignore'); + if (!fs.existsSync(gitignorePath)) return; + + const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n'); + const managedBlock = findGitignoreManagedBlock(lines); + if (!managedBlock) return; + + const managedLines = lines.slice(managedBlock.start + 1, managedBlock.end); + const filteredManagedLines = managedLines.filter((line) => line.trim() !== entry); + if (filteredManagedLines.length === managedLines.length) return; + + const hasManagedEntries = filteredManagedLines.some((line) => line.trim() !== ''); + const filtered = hasManagedEntries + ? [ + ...lines.slice(0, managedBlock.start + 1), + ...filteredManagedLines, + ...lines.slice(managedBlock.end) + ] + : [ + ...lines.slice(0, managedBlock.start), + ...lines.slice(managedBlock.end + 1) + ]; + + await writeFile(gitignorePath, filtered.join('\n')); +}; + +const setCollectionGitRemote = async (workspacePath, collectionPath, remoteUrl) => { + if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') { + throw new Error('A non-empty Git remote URL is required'); + } + const trimmedUrl = remoteUrl.trim(); + + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); + const target = path.normalize(collectionPath); + let matched = false; + + config.collections = (config.collections || []).map((c) => { + if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c; + matched = true; + return { ...c, remote: trimmedUrl }; + }); + + if (!matched) { + throw new Error('Collection not found in workspace'); + } + + await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config)); + await addCollectionToWorkspaceGitignore(workspacePath, collectionPath); + return config; + }); +}; + +const clearCollectionGitRemote = async (workspacePath, collectionPath) => { + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); + const target = path.normalize(collectionPath); + let matched = false; + + config.collections = (config.collections || []).map((c) => { + if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c; + matched = true; + const updated = { ...c }; + delete updated.remote; + return updated; + }); + + if (!matched) { + throw new Error('Collection not found in workspace'); + } + + await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config)); + await removeCollectionFromWorkspaceGitignore(workspacePath, collectionPath); + return config; + }); +}; + const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => { return withLock(getWorkspaceLockKey(workspacePath), async () => { const config = readWorkspaceConfig(workspacePath); @@ -432,36 +556,34 @@ const reorderWorkspaceCollections = async (workspacePath, collectionPaths) => { }); }; -const getWorkspaceCollections = (workspacePath) => { - const config = readWorkspaceConfig(workspacePath); - const collections = config.collections || []; - +const resolveAndFilterWorkspaceCollections = (workspacePath, rawCollections) => { const seenPaths = new Set(); - return collections + + return (rawCollections || []) .map((collection) => { - const collectionPath = collection.path ? posixifyPath(collection.path) : collection.path; - if (collectionPath && !path.isAbsolute(collectionPath)) { - return { - ...collection, - path: path.resolve(workspacePath, collectionPath) - }; - } - return { ...collection, path: collectionPath }; + if (!collection.path) return collection; + const collectionPath = posixifyPath(collection.path); + const absolute = path.isAbsolute(collectionPath) + ? collectionPath + : path.resolve(workspacePath, collectionPath); + return { ...collection, path: absolute }; }) - .filter((collection) => { - if (!collection.path) { - return false; - } + .map((collection) => { + if (!collection.path) return null; const normalizedPath = path.normalize(collection.path); - if (seenPaths.has(normalizedPath)) { - return false; - } + if (seenPaths.has(normalizedPath)) return null; seenPaths.add(normalizedPath); - if (!isValidCollectionDirectory(collection.path)) { - return false; - } - return true; - }); + + if (isValidCollectionDirectory(collection.path)) return collection; + if (collection.remote) return { ...collection, notFoundLocally: true }; + return null; + }) + .filter(Boolean); +}; + +const getWorkspaceCollections = (workspacePath) => { + const config = readWorkspaceConfig(workspacePath); + return resolveAndFilterWorkspaceCollections(workspacePath, config.collections); }; const getWorkspaceApiSpecs = (workspacePath) => { @@ -571,8 +693,11 @@ module.exports = { updateWorkspaceDocs, addCollectionToWorkspace, removeCollectionFromWorkspace, + setCollectionGitRemote, + clearCollectionGitRemote, reorderWorkspaceCollections, getWorkspaceCollections, + resolveAndFilterWorkspaceCollections, getWorkspaceApiSpecs, addApiSpecToWorkspace, removeApiSpecFromWorkspace, diff --git a/packages/bruno-electron/tests/utils/workspace-config.spec.js b/packages/bruno-electron/tests/utils/workspace-config.spec.js index e706372143c..a7a8951b92e 100644 --- a/packages/bruno-electron/tests/utils/workspace-config.spec.js +++ b/packages/bruno-electron/tests/utils/workspace-config.spec.js @@ -2,9 +2,14 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const yaml = require('js-yaml'); -const { reorderWorkspaceCollections } = require('../../src/utils/workspace-config'); +const { + reorderWorkspaceCollections, + setCollectionGitRemote, + clearCollectionGitRemote, + getWorkspaceCollections +} = require('../../src/utils/workspace-config'); -const collection = (name, pathSegment) => ({ name, path: pathSegment }); +const collection = (name, pathSegment, extra = {}) => ({ name, path: pathSegment, ...extra }); describe('reorderWorkspaceCollections', () => { let workspacePath; @@ -74,3 +79,192 @@ describe('reorderWorkspaceCollections', () => { expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']); }); }); + +describe('Git remote on workspace collections', () => { + let workspacePath; + + const writeYml = (collections) => { + const lines = [ + 'opencollection: 1.0.0', + 'info:', + ' name: Test', + ' type: workspace', + 'collections:' + ]; + for (const c of collections) { + lines.push(` - name: "${c.name}"`); + lines.push(` path: "${c.path}"`); + if (c.remote) lines.push(` remote: "${c.remote}"`); + } + lines.push('specs: []'); + lines.push('docs: \'\''); + fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), lines.join('\n')); + }; + + const readCollectionsFromYml = () => { + const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); + return (yaml.load(raw).collections || []); + }; + + const absPath = (relativePath) => path.resolve(workspacePath, relativePath); + + const ensureCollectionDir = (relativePath) => { + const dir = path.join(workspacePath, relativePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' })); + }; + + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-git-')); + }); + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + test('setCollectionGitRemote sets remote on the matching entry only', async () => { + writeYml([ + collection('API', 'collections/api'), + collection('Backend', 'collections/backend') + ]); + + await setCollectionGitRemote(workspacePath, absPath('collections/backend'), 'https://github.com/x/backend'); + + const entries = readCollectionsFromYml(); + expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' }); + expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' }); + }); + + test('setCollectionGitRemote rejects empty URL', async () => { + writeYml([collection('API', 'collections/api')]); + await expect( + setCollectionGitRemote(workspacePath, absPath('collections/api'), ' ') + ).rejects.toThrow(/non-empty/i); + }); + + test('setCollectionGitRemote throws when collection is missing from workspace.yml', async () => { + writeYml([collection('API', 'collections/api')]); + await expect( + setCollectionGitRemote(workspacePath, absPath('collections/missing'), 'https://github.com/x/y') + ).rejects.toThrow(/not found/i); + }); + + test('clearCollectionGitRemote removes only the remote field', async () => { + writeYml([ + collection('API', 'collections/api', { remote: 'https://github.com/x/api' }), + collection('Backend', 'collections/backend', { remote: 'https://github.com/x/backend' }) + ]); + + await clearCollectionGitRemote(workspacePath, absPath('collections/api')); + + const entries = readCollectionsFromYml(); + expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' }); + expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' }); + }); + + test('getWorkspaceCollections keeps git-backed entries even when local folder is missing', () => { + ensureCollectionDir('collections/api'); + writeYml([ + collection('API', 'collections/api'), + collection('Missing', 'collections/missing', { remote: 'https://github.com/x/missing' }) + ]); + + const result = getWorkspaceCollections(workspacePath); + + expect(result).toHaveLength(2); + const api = result.find((r) => r.name === 'API'); + const missing = result.find((r) => r.name === 'Missing'); + expect(api.notFoundLocally).toBeUndefined(); + expect(missing.notFoundLocally).toBe(true); + expect(missing.remote).toBe('https://github.com/x/missing'); + }); + + test('getWorkspaceCollections still drops missing entries that have no remote', () => { + writeYml([collection('Missing', 'collections/missing')]); + expect(getWorkspaceCollections(workspacePath)).toHaveLength(0); + }); + + test('setCollectionGitRemote adds the collection path to .gitignore', async () => { + ensureCollectionDir('collections/api'); + writeYml([collection('API', 'collections/api')]); + + await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api'); + + const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8'); + expect(gitignore.split('\n')).toContain('collections/api/'); + }); + + test('setCollectionGitRemote does not duplicate the .gitignore entry on repeated calls', async () => { + ensureCollectionDir('collections/api'); + writeYml([collection('API', 'collections/api')]); + + await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api'); + await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api-renamed'); + + const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8'); + const matches = gitignore.split('\n').filter((line) => line.trim() === 'collections/api/'); + expect(matches).toHaveLength(1); + }); + + test('setCollectionGitRemote preserves existing .gitignore content', async () => { + ensureCollectionDir('collections/api'); + writeYml([collection('API', 'collections/api')]); + fs.writeFileSync(path.join(workspacePath, '.gitignore'), '# user notes\nnode_modules\n.env\n'); + + await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api'); + + const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8'); + expect(gitignore).toContain('# user notes'); + expect(gitignore).toContain('node_modules'); + expect(gitignore).toContain('.env'); + expect(gitignore).toContain('collections/api/'); + }); + + test('clearCollectionGitRemote removes the collection path from .gitignore', async () => { + ensureCollectionDir('collections/api'); + writeYml([collection('API', 'collections/api', { remote: 'https://github.com/x/api' })]); + fs.writeFileSync(path.join(workspacePath, '.gitignore'), [ + 'node_modules', + '# Bruno managed collection remotes', + 'collections/api/', + '# End Bruno managed collection remotes', + '' + ].join('\n')); + + await clearCollectionGitRemote(workspacePath, absPath('collections/api')); + + const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8'); + expect(gitignore.split('\n')).not.toContain('collections/api/'); + expect(gitignore).toContain('node_modules'); + }); + + test('clearCollectionGitRemote preserves user-owned .gitignore entries', async () => { + ensureCollectionDir('collections/api'); + writeYml([collection('API', 'collections/api')]); + fs.writeFileSync(path.join(workspacePath, '.gitignore'), 'node_modules\ncollections/api/\n'); + + await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api'); + await clearCollectionGitRemote(workspacePath, absPath('collections/api')); + + const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8'); + expect(gitignore.split('\n')).toContain('collections/api/'); + expect(gitignore).toContain('node_modules'); + }); + + test('setCollectionGitRemote skips .gitignore for collections outside the workspace', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-outside-')); + fs.writeFileSync(path.join(outsideDir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' })); + try { + writeYml([collection('External', outsideDir)]); + await setCollectionGitRemote(workspacePath, outsideDir, 'https://github.com/x/external'); + + const gitignorePath = path.join(workspacePath, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + const gitignore = fs.readFileSync(gitignorePath, 'utf8'); + expect(gitignore).not.toContain(outsideDir); + } + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index f826b37b52d..22078efe25d 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -8,6 +8,7 @@ const { interpolateString } = require('../interpolate-string'); const { executeQuickJsVm } = require('../sandbox/quickjs'); const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); const { expect } = chai; chai.use(require('chai-string')); chai.use(function (chai, utils) { @@ -26,9 +27,30 @@ chai.use(function (chai, utils) { }); // Custom assertion for JSON Schema validation +const defaultAjv = new Ajv({ allErrors: true }); +addFormats(defaultAjv); + +const SUPPORTED_SCHEMA_VERSIONS = [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema' +]; + chai.use(function (chai) { chai.Assertion.addMethod('jsonSchema', function (schema, ajvOptions) { - const ajv = new Ajv({ allErrors: true, ...ajvOptions }); + if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) { + this.assert( + false, + `Unsupported JSON Schema version: "${schema.$schema}". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.`, + `Unsupported JSON Schema version: "${schema.$schema}".` + ); + } + let ajv; + if (ajvOptions) { + ajv = new Ajv({ allErrors: true, ...ajvOptions }); + addFormats(ajv); + } else { + ajv = defaultAjv; + } let validate; try { validate = ajv.compile(schema); diff --git a/packages/bruno-js/src/sandbox/bundle-libraries.js b/packages/bruno-js/src/sandbox/bundle-libraries.js index 34041363fdb..58abf568dc6 100644 --- a/packages/bruno-js/src/sandbox/bundle-libraries.js +++ b/packages/bruno-js/src/sandbox/bundle-libraries.js @@ -15,6 +15,7 @@ const bundleLibraries = async () => { import * as cryptoJs from 'crypto-js'; import tv4 from "tv4"; import Ajv from "ajv"; + import addFormats from "ajv-formats"; globalThis.expect = expect; globalThis.assert = assert; globalThis.moment = moment; @@ -23,6 +24,7 @@ const bundleLibraries = async () => { globalThis.Buffer = Buffer; globalThis.tv4 = tv4; globalThis.Ajv = Ajv; + globalThis.addFormats = addFormats; globalThis.requireObject = { ...(globalThis.requireObject || {}), 'chai': { expect, assert }, @@ -32,7 +34,8 @@ const bundleLibraries = async () => { 'atob': atob, 'crypto-js': cryptoJs, 'tv4': tv4, - 'ajv': Ajv + 'ajv': Ajv, + 'ajv-formats': addFormats }; `; diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js index e3666e5c5e4..d2c7f7dbb34 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/test.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js @@ -84,9 +84,29 @@ const addBruShimToContext = (vm, __brunoTestResults) => { ` (function() { var Ajv = require('ajv'); + var addFormats = require('ajv-formats'); + var defaultAjv = new Ajv({ allErrors: true }); + addFormats(defaultAjv); + var SUPPORTED_SCHEMA_VERSIONS = [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema' + ]; var proto = Object.getPrototypeOf(expect(null)); proto.jsonSchema = function(schema, ajvOptions) { - var ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions || {})); + if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) { + this.assert( + false, + 'Unsupported JSON Schema version: "' + schema.$schema + '". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.', + 'Unsupported JSON Schema version: "' + schema.$schema + '".' + ); + } + var ajv; + if (ajvOptions) { + ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions)); + addFormats(ajv); + } else { + ajv = defaultAjv; + } var validate; try { validate = ajv.compile(schema); diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index 4b00b4391e8..0ed6733c606 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -501,6 +501,40 @@ describe('runtime', () => { const schema = { type: 'array' }; chai.expect(body).to.not.have.jsonSchema(schema); }); + + it('should throw a clear error for unsupported Draft 2020-12 $schema', () => { + const body = { name: 'John' }; + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } } + }; + expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2020-12.*only supports Draft-07/); + }); + + it('should throw a clear error for unsupported Draft 2019-09 $schema', () => { + const body = { name: 'John' }; + const schema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { name: { type: 'string' } } + }; + expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2019-09.*only supports Draft-07/); + }); + + it('should allow explicit Draft-07 $schema', () => { + const body = { name: 'John', age: 30 }; + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + }; + chai.expect(body).to.have.jsonSchema(schema); + }); }); }); }); diff --git a/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru index 027d9c9e77d..0eb68dee926 100644 --- a/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru +++ b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru @@ -23,7 +23,27 @@ body:json { "street": "123 Main St", "city": "Springfield", "zip": "62701" - } + }, + "website": "https://example.com/john", + "createdAt": "2024-01-15T10:30:00Z", + "birthDate": "1994-05-20", + "loginTime": "10:30:00Z", + "ipv4": "192.168.1.1", + "ipv6": "::1", + "id": "550e8400-e29b-41d4-a716-446655440000", + "encodedData": "SGVsbG8gV29ybGQ=", + "int32Val": 2147483647, + "int64Val": 2147483648, + "floatVal": 3.14, + "doubleVal": 1.7976931348623157e+308, + "duration": "P3Y6M4DT12H30M5S", + "hostname": "example.com", + "regexPattern": "^[a-z]+$", + "jsonPointer": "/foo/bar/0", + "uriRef": "/relative/path", + "uriTemplate": "https://example.com/{user}", + "invalidRegex": "[invalid", + "invalidUriTemplate": "https://example.com/{invalid" } } @@ -166,7 +186,7 @@ tests { expect(res.getBody()).to.have.jsonSchema(schema); }); - test("jsonSchema with ajvOptions", function() { + test("jsonSchema with ajvOptions - allErrors", function() { const schema = { type: 'object', properties: { @@ -178,6 +198,525 @@ tests { expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true }); }); + test("jsonSchema with ajvOptions - format validation with allErrors", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + website: { type: 'string', format: 'uri' } + }, + required: ['email', 'website'] + }; + expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - format rejection with allErrors", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'email' }, + age: { type: 'string', format: 'uri' } + }, + required: ['name', 'age'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - additionalProperties false", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false + }; + expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - coerceTypes allows string as number", function() { + const schema = { + type: 'object', + properties: { + zip: { type: 'integer' } + }, + required: ['zip'] + }; + // zip is "62701" (string) - fails without coercion + expect(res.getBody().address).to.not.have.jsonSchema(schema); + // passes with coerceTypes since "62701" can be coerced to integer + expect(res.getBody().address).to.have.jsonSchema(schema, { coerceTypes: true }); + }); + + test("jsonSchema with ajvOptions - coerceTypes allows number as string", function() { + const schema = { + type: 'object', + properties: { + age: { type: 'string' } + }, + required: ['age'] + }; + // age is 30 (number) - fails without coercion + expect(res.getBody()).to.not.have.jsonSchema(schema); + // passes with coerceTypes since 30 can be coerced to "30" + expect(res.getBody()).to.have.jsonSchema(schema, { coerceTypes: true }); + }); + + test("jsonSchema with ajvOptions - strict false allows unknown keywords", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', customKeyword: true } + }, + required: ['name'] + }; + // unknown keyword "customKeyword" throws in strict mode (default) + expect(() => expect(res.getBody()).to.have.jsonSchema(schema)).to.throw('JSON schema compile error'); + // passes with strict: false + expect(res.getBody()).to.have.jsonSchema(schema, { strict: false }); + }); + + // --- ajv-formats: Passing validations --- + + test("format: email - valid email", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' } + }, + required: ['email'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri - valid URI", function() { + const schema = { + type: 'object', + properties: { + website: { type: 'string', format: 'uri' } + }, + required: ['website'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: date-time - valid ISO 8601 date-time", function() { + const schema = { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'date-time' } + }, + required: ['createdAt'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: date - valid date", function() { + const schema = { + type: 'object', + properties: { + birthDate: { type: 'string', format: 'date' } + }, + required: ['birthDate'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: time - valid time", function() { + const schema = { + type: 'object', + properties: { + loginTime: { type: 'string', format: 'time' } + }, + required: ['loginTime'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: ipv4 - valid IPv4 address", function() { + const schema = { + type: 'object', + properties: { + ipv4: { type: 'string', format: 'ipv4' } + }, + required: ['ipv4'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: ipv6 - valid IPv6 address", function() { + const schema = { + type: 'object', + properties: { + ipv6: { type: 'string', format: 'ipv6' } + }, + required: ['ipv6'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uuid - valid UUID", function() { + const schema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' } + }, + required: ['id'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: byte - valid base64 string", function() { + const schema = { + type: 'object', + properties: { + encodedData: { type: 'string', format: 'byte' } + }, + required: ['encodedData'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: int32 - valid 32-bit integer", function() { + const schema = { + type: 'object', + properties: { + int32Val: { type: 'integer', format: 'int32' } + }, + required: ['int32Val'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: int64 - valid 64-bit integer", function() { + const schema = { + type: 'object', + properties: { + int64Val: { type: 'integer', format: 'int64' } + }, + required: ['int64Val'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: float - valid float", function() { + const schema = { + type: 'object', + properties: { + floatVal: { type: 'number', format: 'float' } + }, + required: ['floatVal'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: double - valid double", function() { + const schema = { + type: 'object', + properties: { + doubleVal: { type: 'number', format: 'double' } + }, + required: ['doubleVal'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: duration - valid ISO 8601 duration", function() { + const schema = { + type: 'object', + properties: { + duration: { type: 'string', format: 'duration' } + }, + required: ['duration'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: hostname - valid hostname", function() { + const schema = { + type: 'object', + properties: { + hostname: { type: 'string', format: 'hostname' } + }, + required: ['hostname'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: regex - valid regex pattern", function() { + const schema = { + type: 'object', + properties: { + regexPattern: { type: 'string', format: 'regex' } + }, + required: ['regexPattern'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: json-pointer - valid JSON pointer", function() { + const schema = { + type: 'object', + properties: { + jsonPointer: { type: 'string', format: 'json-pointer' } + }, + required: ['jsonPointer'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri-reference - valid URI reference", function() { + const schema = { + type: 'object', + properties: { + uriRef: { type: 'string', format: 'uri-reference' } + }, + required: ['uriRef'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri-template - valid URI template", function() { + const schema = { + type: 'object', + properties: { + uriTemplate: { type: 'string', format: 'uri-template' } + }, + required: ['uriTemplate'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("Multiple formats in one schema", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + website: { type: 'string', format: 'uri' }, + createdAt: { type: 'string', format: 'date-time' }, + ipv4: { type: 'string', format: 'ipv4' }, + id: { type: 'string', format: 'uuid' }, + encodedData: { type: 'string', format: 'byte' }, + int32Val: { type: 'integer', format: 'int32' }, + floatVal: { type: 'number', format: 'float' }, + duration: { type: 'string', format: 'duration' }, + hostname: { type: 'string', format: 'hostname' } + }, + required: ['email', 'website', 'createdAt', 'ipv4', 'id', 'encodedData', 'int32Val', 'floatVal', 'duration', 'hostname'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + // --- ajv-formats: Failure validations --- + + test("format: email - rejects non-email string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'email' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri - rejects non-URI string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'uri' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: date-time - rejects plain date string", function() { + const schema = { + type: 'object', + properties: { + birthDate: { type: 'string', format: 'date-time' } + }, + required: ['birthDate'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: ipv4 - rejects non-IP string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'ipv4' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uuid - rejects non-UUID string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'uuid' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: byte - rejects non-base64 string", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'byte' } + }, + required: ['email'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: int32 - rejects value exceeding 32-bit range", function() { + const schema = { + type: 'object', + properties: { + int64Val: { type: 'integer', format: 'int32' } + }, + required: ['int64Val'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: float - rejects non-number field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'number', format: 'float' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: duration - rejects non-duration string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'duration' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: hostname - rejects invalid hostname", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'hostname' } + }, + required: ['email'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: json-pointer - rejects non-pointer string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'json-pointer' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: date - rejects non-date string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'date' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: time - rejects non-time string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'time' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: ipv6 - rejects non-IPv6 string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'ipv6' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: int64 - rejects non-integer field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'integer', format: 'int64' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: double - rejects non-number field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'number', format: 'double' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: regex - rejects invalid regex pattern", function() { + const schema = { + type: 'object', + properties: { + invalidRegex: { type: 'string', format: 'regex' } + }, + required: ['invalidRegex'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri-reference - rejects invalid URI reference string", function() { + const schema = { + type: 'object', + properties: { + invalidRegex: { type: 'string', format: 'uri-reference' } + }, + required: ['invalidRegex'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri-template - rejects invalid URI template string", function() { + const schema = { + type: 'object', + properties: { + invalidUriTemplate: { type: 'string', format: 'uri-template' } + }, + required: ['invalidUriTemplate'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + // --- Failure validations --- test("Type mismatch - schema expects array, response is object", function() { diff --git a/tests/variable-tooltip/variable-tooltip.spec.ts b/tests/variable-tooltip/variable-tooltip.spec.ts index cf8eba4d8a8..78a9a624845 100644 --- a/tests/variable-tooltip/variable-tooltip.spec.ts +++ b/tests/variable-tooltip/variable-tooltip.spec.ts @@ -431,4 +431,211 @@ test.describe('Variable Tooltip', () => { await expect(tooltip.locator('.var-value-editable-display')).not.toBeVisible(); }); }); + + test('should keep tooltip open while editing when mouse leaves popup area', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-pin-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-pin-collection')); + + await createEnvironment(page, 'Pin Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'pinVar', value: 'pin-value' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Pin Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Pin Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{pinVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('Tooltip stays open and accepts input while mouse is outside popup', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const pinVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'pinVar' }).first(); + await pinVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + // Click value display to enter edit mode (this also pins the popup) + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + // Move mouse far outside the popup + await page.mouse.move(0, 0); + + // Type with a per-keystroke delay so the typing window spans past the internal + // 500ms hide timer. If the popup were not pinned, it would hide mid-typing and + // the keystrokes would never reach the editor — the assertion below would fail. + // This validates pinning via real editor activity instead of a fixed sleep. + await page.keyboard.press('End'); + await page.keyboard.type('-still-editable-after-mouse-left', { delay: 25 }); + + await expect(editor.locator('.CodeMirror-line')).toContainText( + 'pin-value-still-editable-after-mouse-left' + ); + await expect(tooltip).toBeVisible(); + }); + }); + + test('should persist subsequent edits while popup stays open', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-subsequent-edit-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-subsequent-collection')); + + await createEnvironment(page, 'Edit Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'editVar', value: 'initial' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Edit Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Edit Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{editVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('First edit saves via Enter and keeps popup open', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const editVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'editVar' }).first(); + await editVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await expect(valueDisplay).toContainText('initial'); + + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-one'); + + // Pressing Enter saves and keeps the popup open (does not click outside) + await page.keyboard.press('Enter'); + + // Display reflects the saved value, and tooltip is still visible + await expect(valueDisplay).toContainText('initial-one'); + await expect(tooltip).toBeVisible(); + }); + + await test.step('Second edit on the same popup also saves', async () => { + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-two'); + + await page.keyboard.press('Enter'); + + await expect(valueDisplay).toContainText('initial-one-two'); + }); + + await test.step('Reopen tooltip and verify the second edit persisted', async () => { + // Close the existing tooltip with an outside click, then re-hover to get a fresh one + await page.locator('body').click(); + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).not.toBeVisible(); + + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const editVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'editVar' }).first(); + await editVar.hover(); + + const newTooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(newTooltip).toBeVisible(); + await expect(newTooltip.locator('.var-value-editable-display')).toContainText('initial-one-two'); + }); + }); + + test('should copy latest value after editing within the same tooltip', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-copy-latest-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-copy-latest-collection')); + + await createEnvironment(page, 'Copy Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'copyVar', value: 'original-copy' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Copy Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Copy Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{copyVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('Copy button copies the initial value', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const copyVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'copyVar' }).first(); + await copyVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const copyButton = tooltip.locator('.copy-button'); + await copyButton.click(); + + // Success state confirms writeText resolved before we read the clipboard + await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); + + const initialClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(initialClipboard).toBe('original-copy'); + + // Wait for the icon to revert so the next click is allowed + await expect(copyButton.locator('svg rect')).toBeVisible(); + }); + + await test.step('Edit value, save with Enter, then copy without re-hovering', async () => { + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-edited'); + + await page.keyboard.press('Enter'); + + // Wait for the display to reflect the saved value before clicking copy + await expect(valueDisplay).toContainText('original-copy-edited'); + + const copyButton = tooltip.locator('.copy-button'); + await copyButton.click(); + + await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); + + const updatedClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(updatedClipboard).toBe('original-copy-edited'); + }); + }); }); diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore new file mode 100644 index 00000000000..9010754257a --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json new file mode 100644 index 00000000000..b2d19af4817 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/collections/sample-coll/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "SampleColl", + "type": "collection" +} diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml new file mode 100644 index 00000000000..5c5e2979172 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-collection/workspace.yml @@ -0,0 +1,12 @@ +opencollection: 1.0.0 +info: + name: "Fixture WS" + type: workspace + +collections: + - name: "SampleColl" + path: "collections/sample-coll" + +specs: + +docs: '' diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore new file mode 100644 index 00000000000..9010754257a --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db diff --git a/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml new file mode 100644 index 00000000000..b5da4c28968 --- /dev/null +++ b/tests/workspace/git-backed-collections/fixtures/workspace-with-ghost/workspace.yml @@ -0,0 +1,13 @@ +opencollection: 1.0.0 +info: + name: "Ghost WS" + type: workspace + +collections: + - name: "Missing Coll" + path: "collections/missing-coll" + remote: "https://github.com/usebruno/sample-collection.git" + +specs: + +docs: '' diff --git a/tests/workspace/git-backed-collections/git-backed-collections.spec.ts b/tests/workspace/git-backed-collections/git-backed-collections.spec.ts new file mode 100644 index 00000000000..93b26913831 --- /dev/null +++ b/tests/workspace/git-backed-collections/git-backed-collections.spec.ts @@ -0,0 +1,291 @@ +import path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; +import { test, expect, closeElectronApp } from '../../../playwright'; +import { switchWorkspace, createCollection } from '../../utils/page'; + +type CollectionEntry = { name?: string; path?: string; remote?: string }; +type WorkspaceConfig = { collections?: CollectionEntry[] }; + +const initUserDataPath = path.join(__dirname, 'init-user-data'); +const fixturesPath = path.join(__dirname, 'fixtures'); + +const REMOTE_URL = 'https://github.com/usebruno/sample-collection.git'; + +const FIXTURE_WS_NAME = 'Fixture WS'; +const GHOST_WS_NAME = 'Ghost WS'; +const SAMPLE_COLL_GITIGNORE_LINE = 'collections/sample-coll/'; + +async function copyFixture(fixtureName: string, destDir: string): Promise { + const src = path.join(fixturesPath, fixtureName); + await fs.promises.cp(src, destDir, { recursive: true }); + return destDir; +} + +function readWorkspaceYml(workspacePath: string): WorkspaceConfig { + const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); + return yaml.load(raw) as WorkspaceConfig; +} + +function readGitignoreLines(workspacePath: string): string[] { + const gitignorePath = path.join(workspacePath, '.gitignore'); + if (!fs.existsSync(gitignorePath)) return []; + return fs.readFileSync(gitignorePath, 'utf8').split('\n'); +} + +test.describe('Git-backed collections', () => { + test.describe('Workspace overview', () => { + test('connect to Git updates workspace.yml, shows badge + remote URL, and adds .gitignore entry', async ({ launchElectronApp, createTmpDir }) => { + const workspacePath = await createTmpDir('git-ws-connect'); + await copyFixture('workspace-with-collection', workspacePath); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await switchWorkspace(page, FIXTURE_WS_NAME); + + await test.step('Navigate to workspace overview', async () => { + await page.locator('.titlebar-left .home-button').click(); + }); + + const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' }); + + await test.step('Open Connect to Git modal from collection menu', async () => { + await card.waitFor({ state: 'visible', timeout: 5000 }); + await card.locator('.collection-menu').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click(); + }); + + await test.step('Submit the modal with a Git URL', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + await modal.locator('#remoteUrl').fill(REMOTE_URL); + await modal.getByRole('button', { name: 'Connect', exact: true }).click(); + await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify Git badge and remote URL render on the card', async () => { + await expect(card.locator('.collection-remote')).toContainText(REMOTE_URL, { timeout: 5000 }); + await expect(card.getByText('Git', { exact: true })).toBeVisible(); + }); + + await test.step('Verify workspace.yml records the remote on the matching entry', async () => { + const config = readWorkspaceYml(workspacePath); + const entry = config.collections?.find((c) => c.name === 'SampleColl'); + expect(entry?.remote).toBe(REMOTE_URL); + }); + + await test.step('Verify .gitignore contains the collection path', async () => { + expect(readGitignoreLines(workspacePath)).toContain(SAMPLE_COLL_GITIGNORE_LINE); + }); + + await closeElectronApp(app); + }); + + test('remove Git remote clears the badge, the workspace.yml field, and the .gitignore line', async ({ launchElectronApp, createTmpDir }) => { + const workspacePath = await createTmpDir('git-ws-disconnect'); + await copyFixture('workspace-with-collection', workspacePath); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await switchWorkspace(page, FIXTURE_WS_NAME); + + const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' }); + + await test.step('Connect the collection to Git first', async () => { + await page.locator('.titlebar-left .home-button').click(); + await card.locator('.collection-menu').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click(); + + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' }); + await modal.locator('#remoteUrl').fill(REMOTE_URL); + await modal.getByRole('button', { name: 'Connect', exact: true }).click(); + await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Open Remove Git Remote modal', async () => { + await card.locator('.collection-menu').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' }).click(); + + const removeModal = page.locator('.bruno-modal-card').filter({ hasText: 'Remove Git Remote' }); + await removeModal.waitFor({ state: 'visible', timeout: 5000 }); + await removeModal.getByRole('button', { name: 'Remove', exact: true }).click(); + await expect(page.getByText('Git remote removed')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify badge and remote URL line are gone from the card', async () => { + await expect(card.locator('.collection-remote')).toHaveCount(0, { timeout: 5000 }); + await expect(card.getByText('Git', { exact: true })).toHaveCount(0); + }); + + await test.step('Verify workspace.yml no longer carries the remote field', async () => { + const config = readWorkspaceYml(workspacePath); + const entry = config.collections?.find((c) => c.name === 'SampleColl'); + expect(entry).toBeDefined(); + expect(entry?.remote).toBeUndefined(); + }); + + await test.step('Verify .gitignore no longer contains the collection path', async () => { + expect(readGitignoreLines(workspacePath)).not.toContain(SAMPLE_COLL_GITIGNORE_LINE); + }); + + await closeElectronApp(app); + }); + + test('Connect to Git modal rejects empty and invalid URLs', async ({ launchElectronApp, createTmpDir }) => { + const workspacePath = await createTmpDir('git-ws-validation'); + await copyFixture('workspace-with-collection', workspacePath); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await switchWorkspace(page, FIXTURE_WS_NAME); + + await test.step('Open Connect to Git modal', async () => { + await page.locator('.titlebar-left .home-button').click(); + const card = page.locator('.collection-card').filter({ hasText: 'SampleColl' }); + await card.locator('.collection-menu').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' }).click(); + }); + + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Connect to Git' }); + + await test.step('Empty URL should show validation error and keep modal open', async () => { + await modal.waitFor({ state: 'visible', timeout: 5000 }); + await modal.locator('#remoteUrl').fill(''); + await modal.getByRole('button', { name: 'Connect', exact: true }).click(); + + await expect(modal.locator('.text-red-500').first()).toBeVisible({ timeout: 2000 }); + await expect(modal).toBeVisible(); + }); + + await test.step('Malformed URL should also be rejected', async () => { + await modal.locator('#remoteUrl').fill('not-a-url'); + await modal.getByRole('button', { name: 'Connect', exact: true }).click(); + + await expect(modal.locator('.text-red-500').first()).toBeVisible({ timeout: 2000 }); + await expect(modal).toBeVisible(); + }); + + await test.step('Valid URL submits successfully', async () => { + await modal.locator('#remoteUrl').fill(REMOTE_URL); + await modal.getByRole('button', { name: 'Connect', exact: true }).click(); + await expect(page.getByText('Git remote connected')).toBeVisible({ timeout: 10000 }); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + + test('default workspace does not expose Git options on the collection card', async ({ launchElectronApp, createTmpDir }) => { + // No fixture: the playwright fixture default-seeds preferences to skip onboarding, + // and Bruno auto-creates the default workspace under the userData path. + const collectionDir = await createTmpDir('git-default-coll'); + + const app = await launchElectronApp(); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Verify we are on the default workspace', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await test.step('Create a collection in the default workspace', async () => { + await createCollection(page, 'DefaultColl', collectionDir); + }); + + await test.step('Open the collection menu in the workspace overview', async () => { + await page.locator('.titlebar-left .home-button').click(); + const card = page.locator('.collection-card').filter({ hasText: 'DefaultColl' }); + await card.waitFor({ state: 'visible', timeout: 5000 }); + await card.locator('.collection-menu').click(); + }); + + await test.step('No Git-related menu items should be visible', async () => { + await expect(page.locator('.dropdown-item').filter({ hasText: 'Connect to Git' })).toHaveCount(0); + await expect(page.locator('.dropdown-item').filter({ hasText: 'Copy Git URL' })).toHaveCount(0); + await expect(page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' })).toHaveCount(0); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Sidebar ghost row', () => { + test('git-backed entry whose folder is missing renders as a clickable ghost row', async ({ launchElectronApp, createTmpDir }) => { + const workspacePath = await createTmpDir('git-ws-ghost'); + await copyFixture('workspace-with-ghost', workspacePath); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await switchWorkspace(page, GHOST_WS_NAME); + + const ghostRow = page.getByTestId('sidebar-git-collection-row').filter({ hasText: 'Missing Coll' }); + + await test.step('Ghost row appears in the sidebar', async () => { + await expect(ghostRow).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Ghost row is not also rendered as a normal sidebar collection row', async () => { + await expect( + page.getByTestId('sidebar-collection-row').filter({ hasText: 'Missing Coll' }) + ).toHaveCount(0); + }); + + await test.step('Clicking the ghost row opens the Clone Git Repository modal pre-filled with the remote URL', async () => { + await ghostRow.click(); + const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: 'Clone' }); + await cloneModal.waitFor({ state: 'visible', timeout: 5000 }); + await expect(cloneModal).toContainText(REMOTE_URL); + }); + + await closeElectronApp(app); + }); + + test('right-clicking a ghost row exposes Remove Git Remote, which prunes both the entry and the row', async ({ launchElectronApp, createTmpDir }) => { + const workspacePath = await createTmpDir('git-ws-ghost-remove'); + await copyFixture('workspace-with-ghost', workspacePath); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { workspacePath } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await switchWorkspace(page, GHOST_WS_NAME); + + const ghostRow = page.getByTestId('sidebar-git-collection-row').filter({ hasText: 'Missing Coll' }); + + await test.step('Wait for the ghost row to appear', async () => { + await expect(ghostRow).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Right-click the ghost row and choose Remove Git Remote', async () => { + await ghostRow.click({ button: 'right' }); + await page.locator('.dropdown-item').filter({ hasText: 'Remove Git Remote' }).click(); + + const removeModal = page.locator('.bruno-modal-card').filter({ hasText: 'Remove Git Remote' }); + await removeModal.waitFor({ state: 'visible', timeout: 5000 }); + await removeModal.getByRole('button', { name: 'Remove', exact: true }).click(); + await expect(page.getByText('Git remote removed')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Ghost row disappears once the remote field is removed', async () => { + await expect(ghostRow).toHaveCount(0, { timeout: 5000 }); + }); + + await test.step('workspace.yml entry persists but no longer has the remote field', async () => { + const config = readWorkspaceYml(workspacePath); + const entry = config.collections?.find((c) => c.name === 'Missing Coll'); + expect(entry).toBeDefined(); + expect(entry?.remote).toBeUndefined(); + }); + + await closeElectronApp(app); + }); + }); +}); diff --git a/tests/workspace/git-backed-collections/init-user-data/preferences.json b/tests/workspace/git-backed-collections/init-user-data/preferences.json new file mode 100644 index 00000000000..630713e0351 --- /dev/null +++ b/tests/workspace/git-backed-collections/init-user-data/preferences.json @@ -0,0 +1,11 @@ +{ + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + }, + "workspaces": { + "lastOpenedWorkspaces": ["{{workspacePath}}"] + } +}