diff --git a/packages/bruno-app/src/components/AppTitleBar/index.js b/packages/bruno-app/src/components/AppTitleBar/index.js index be35a810d..1010d0a88 100644 --- a/packages/bruno-app/src/components/AppTitleBar/index.js +++ b/packages/bruno-app/src/components/AppTitleBar/index.js @@ -160,7 +160,6 @@ const AppTitleBar = () => { try { await dispatch(createWorkspaceWithUniqueName(defaultLocation)); - toast.success('Workspace created!'); } catch (error) { toast.error(error?.message || 'Failed to create workspace'); } diff --git a/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js b/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js index 79483c73a..972b4b3c0 100644 --- a/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js +++ b/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js @@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => { .test('unique-name', 'A workspace with this name already exists', function (value) { if (!value) return true; return !workspaces.some((w) => - w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase() + w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase() ); }) }), diff --git a/packages/bruno-app/src/components/ManageWorkspace/index.js b/packages/bruno-app/src/components/ManageWorkspace/index.js index ff3355cbe..8b5c2d92f 100644 --- a/packages/bruno-app/src/components/ManageWorkspace/index.js +++ b/packages/bruno-app/src/components/ManageWorkspace/index.js @@ -27,7 +27,8 @@ const ManageWorkspace = () => { const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null }); const sortedWorkspaces = useMemo(() => { - return sortWorkspaces(workspaces, preferences); + const persistedWorkspaces = workspaces.filter((w) => !w.isCreating); + return sortWorkspaces(persistedWorkspaces, preferences); }, [workspaces, preferences]); const handleBack = () => { @@ -69,7 +70,6 @@ const ManageWorkspace = () => { try { await dispatch(createWorkspaceWithUniqueName(defaultLocation)); - toast.success('Workspace created!'); } catch (error) { toast.error(error?.message || 'Failed to create workspace'); } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js index 54d4efceb..8210459b6 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -72,19 +72,46 @@ const StyledWrapper = styled.div` padding: 4px 8px; } + .workspace-input-wrapper { + display: flex; + align-items: center; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 3px; + background: ${(props) => props.theme.input.bg}; + min-width: 150px; + + &:focus-within { + border-color: ${(props) => props.theme.input.focusBorder}; + } + } + .workspace-name-input { font-size: 14px; font-weight: 500; padding: 2px 6px; - border: 1px solid ${(props) => props.theme.input.border}; - border-radius: 3px; - background: ${(props) => props.theme.input.bg}; + border: none; + background: transparent; color: ${(props) => props.theme.text}; outline: none; - min-width: 150px; + flex: 1; + min-width: 0; + } - &:focus { - border-color: ${(props) => props.theme.input.focusBorder}; + .cog-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 100%; + border: none; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + opacity: 0.5; + + &:hover { + opacity: 1; } } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index db7700a9b..d3139801a 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -15,7 +15,7 @@ import { IconUpload } from '@tabler/icons'; import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; -import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; +import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; @@ -24,6 +24,7 @@ import toast from 'react-hot-toast'; import Dropdown from 'components/Dropdown'; import MenuDropdown from 'ui/MenuDropdown'; import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; +import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace'; import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; import ToolHint from 'components/ToolHint'; import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; @@ -53,11 +54,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { const [workspaceNameInput, setWorkspaceNameInput] = useState(''); const [workspaceNameError, setWorkspaceNameError] = useState(''); const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); + const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); const switcherRef = useRef(); const workspaceActionsRef = useRef(); const workspaceNameInputRef = useRef(null); const workspaceRenameContainerRef = useRef(null); + const openingAdvancedRef = useRef(false); + const clickedOutsideRef = useRef(false); + const handleSaveRef = useRef(null); + const tempWorkspaceUidRef = useRef(null); + const isSavingRef = useRef(false); const onSwitcherCreate = (ref) => (switcherRef.current = ref); const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref); @@ -73,17 +80,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { }, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]); const handleCancelWorkspaceRename = useCallback(() => { + if (openingAdvancedRef.current) return; + if (currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + return; + } setIsRenamingWorkspace(false); setWorkspaceNameInput(''); setWorkspaceNameError(''); - }, []); + }, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]); useEffect(() => { if (!isRenamingWorkspace) return; const handleClickOutside = (event) => { if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) { - handleCancelWorkspaceRename(); + if (currentWorkspace?.isCreating) { + clickedOutsideRef.current = true; + handleSaveRef.current?.(); + } else { + handleCancelWorkspaceRename(); + } } }; @@ -96,7 +113,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { document.removeEventListener('mousedown', handleClickOutside); clearTimeout(timer); }; - }, [isRenamingWorkspace, handleCancelWorkspaceRename]); + }, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]); const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {}); const { theme } = useTheme(); @@ -267,28 +284,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { }; const handleSaveWorkspaceRename = () => { + const fromOutside = clickedOutsideRef.current; + clickedOutsideRef.current = false; + + if (openingAdvancedRef.current) return; + if (isSavingRef.current) return; + + const trimmedName = workspaceNameInput?.trim(); + if (!trimmedName) { + if (fromOutside && currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + return; + } + setWorkspaceNameError('Name is required'); + return; + } + const error = validateWorkspaceName(workspaceNameInput); if (error) { setWorkspaceNameError(error); + if (fromOutside && currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + } return; } const uid = currentWorkspace?.uid; if (!uid) return; - dispatch(renameWorkspaceAction(uid, workspaceNameInput)) - .then(() => { - toast.success('Workspace renamed!'); - setIsRenamingWorkspace(false); - setWorkspaceNameInput(''); - setWorkspaceNameError(''); - }) - .catch((err) => { - toast.error(err?.message || 'An error occurred while renaming the workspace'); - setWorkspaceNameError(err?.message || 'Failed to rename workspace'); - }); + isSavingRef.current = true; + + if (currentWorkspace?.isCreating) { + dispatch(confirmWorkspaceCreation(uid, trimmedName)) + .then(() => { + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + toast.success('Workspace created!'); + }) + .catch((err) => { + toast.error(err?.message || 'An error occurred while creating the workspace'); + }) + .finally(() => { + isSavingRef.current = false; + }); + } else { + dispatch(renameWorkspaceAction(uid, workspaceNameInput)) + .then(() => { + toast.success('Workspace renamed!'); + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + }) + .catch((err) => { + toast.error(err?.message || 'An error occurred while renaming the workspace'); + setWorkspaceNameError(err?.message || 'Failed to rename workspace'); + }) + .finally(() => { + isSavingRef.current = false; + }); + } }; + // Keep ref in sync so click-outside handler always has the latest save logic + handleSaveRef.current = handleSaveWorkspaceRename; + const handleWorkspaceNameChange = (e) => { setWorkspaceNameInput(e.target.value); if (workspaceNameError) { @@ -306,6 +366,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { } }; + const handleOpenAdvancedCreate = () => { + openingAdvancedRef.current = true; + tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null; + setCreateWorkspaceModalOpen(true); + }; + + const handleAdvancedCreateClose = () => { + openingAdvancedRef.current = false; + setCreateWorkspaceModalOpen(false); + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + const tempUid = tempWorkspaceUidRef.current; + tempWorkspaceUidRef.current = null; + // Clean up the temp workspace (cancelWorkspaceCreation only switches to default + // if the temp workspace was still active, so this is safe after modal success too) + if (tempUid) { + dispatch(cancelWorkspaceCreation(tempUid)); + } + }; + // Check if workspace actions should be shown const showWorkspaceActions = isScratchCollection && currentWorkspace @@ -321,30 +402,46 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { /> )} + {createWorkspaceModalOpen && ( + + )} +
{/* Left side: Switcher dropdown or rename input */}
{isRenamingWorkspace ? (
- +
+ + {currentWorkspace?.isCreating && ( + + )} +
diff --git a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js index 396966f4f..26b9e7af4 100644 --- a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js +++ b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js @@ -40,7 +40,7 @@ const CreateWorkspace = ({ onClose }) => { if (!value) return true; return !workspaces.some((w) => - w.name.toLowerCase() === value.toLowerCase()); + !w.isCreating && w.name && w.name.toLowerCase() === value.toLowerCase()); }), workspaceFolderName: Yup.string() .min(1, 'Must be at least 1 character') 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 5761d605d..0028353d0 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -4,19 +4,17 @@ import { removeWorkspace, setActiveWorkspace, updateWorkspace, - addCollectionToWorkspace, removeCollectionFromWorkspace, updateWorkspaceLoadingState, setWorkspaceScratchCollection } from '../workspaces'; -import { showHomePage } from '../app'; import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions'; import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections'; +import { sanitizeName } from 'utils/common/regex'; import { clearCollectionState } from '../openapi-sync'; import { updateGlobalEnvironments } from '../global-environments'; import { addTab, focusTab } from '../tabs'; import { normalizePath } from 'utils/common/path'; -import { sanitizeName } from 'utils/common/regex'; import toast from 'react-hot-toast'; const { ipcRenderer } = window; @@ -53,20 +51,112 @@ const transformCollection = async (collection, type) => { }; /** - * Creates a workspace with a unique name under the given location + * Creates a temporary workspace in Redux without touching the filesystem. + * The workspace is only persisted to disk when the user confirms the name. */ export const createWorkspaceWithUniqueName = (location) => { return async (dispatch) => { + const { uuid: generateUuid } = await import('utils/common'); + const tempUid = generateUuid(); const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Workspace', location) || 'Untitled Workspace'; - const folderName = sanitizeName(name); - const result = await dispatch(createWorkspaceAction(name, folderName, location)); - if (result?.workspaceUid) { - dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true })); + + dispatch(createWorkspace({ + uid: tempUid, + name, + pathname: null, + collections: [], + isCreating: true, + creationLocation: location + })); + + dispatch(updateWorkspace({ uid: tempUid, isNewlyCreated: true })); + await dispatch(switchWorkspace(tempUid)); + + return { workspaceUid: tempUid }; + }; +}; + +/** + * Confirms creation of a temporary workspace by persisting it to the filesystem. + */ +export const confirmWorkspaceCreation = (tempWorkspaceUid, workspaceName) => { + return async (dispatch, getState) => { + const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid); + if (!tempWorkspace) { + throw new Error('Temporary workspace not found'); } + + const location = tempWorkspace.creationLocation; + if (!location) { + throw new Error('Workspace creation location not found'); + } + + const baseFolderName = sanitizeName(workspaceName); + const folderName = await ipcRenderer?.invoke('renderer:find-unique-folder-name', baseFolderName, location) || baseFolderName; + + const result = await ipcRenderer.invoke( + 'renderer:create-workspace', + workspaceName, + folderName, + location + ); + + const { workspaceUid: realUid, workspacePath, workspaceConfig } = result; + + // Clean up the temp workspace's scratch collection after IPC succeeds + // (doing it before would leave a broken state if the IPC call fails) + if (tempWorkspace.scratchCollectionUid) { + dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid })); + } + + // Remove the temporary workspace + dispatch(removeWorkspace(tempWorkspaceUid)); + + // Ensure the real workspace exists in Redux (the workspace-opened event may or may not have fired yet) + const existing = getState().workspaces.workspaces.find((w) => w.uid === realUid); + if (!existing) { + dispatch(createWorkspace({ + uid: realUid, + pathname: workspacePath, + ...workspaceConfig + })); + } + + dispatch(updateWorkspace({ uid: realUid, name: workspaceName })); + + await dispatch(switchWorkspace(realUid)); + return result; }; }; +/** + * Cancels creation of a temporary workspace, removing it from Redux. + * Only switches to default workspace if the temp workspace was the active one. + */ +export const cancelWorkspaceCreation = (tempWorkspaceUid) => { + return async (dispatch, getState) => { + const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid); + if (!tempWorkspace) return; + + // Clean up the scratch collection if one was mounted + if (tempWorkspace.scratchCollectionUid) { + dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid })); + } + + const wasActive = getState().workspaces.activeWorkspaceUid === tempWorkspaceUid; + dispatch(removeWorkspace(tempWorkspaceUid)); + + // Only switch to default if the cancelled workspace was the active one + if (wasActive) { + const defaultWorkspace = getState().workspaces.workspaces.find((w) => w.type === 'default'); + if (defaultWorkspace) { + await dispatch(switchWorkspace(defaultWorkspace.uid)); + } + } + }; +}; + export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => { return async (dispatch) => { try { diff --git a/tests/workspace/create-workspace/create-workspace.spec.ts b/tests/workspace/create-workspace/create-workspace.spec.ts new file mode 100644 index 000000000..c60ef09d1 --- /dev/null +++ b/tests/workspace/create-workspace/create-workspace.spec.ts @@ -0,0 +1,712 @@ +import path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; +import { test, expect, closeElectronApp } from '../../../playwright'; + +type WorkspaceConfig = { + opencollection?: string; + info?: { name: string; type: string }; + collections?: { name?: string; path?: string }[]; +}; + +const initUserDataPath = path.join(__dirname, 'init-user-data'); + +function findCreatedWorkspaceDirs(location: string): string[] { + return fs.readdirSync(location).filter((e) => { + const fullPath = path.join(location, e); + return ( + fs.statSync(fullPath).isDirectory() + && e !== 'default-workspace' + && fs.existsSync(path.join(fullPath, 'workspace.yml')) + ); + }); +} + +test.describe('Create Workspace', () => { + test.describe('Inline Creation Flow', () => { + test('should create workspace via inline rename and press Enter', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-enter'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Click "Create workspace" from title bar dropdown', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + }); + + await test.step('Verify inline rename input appears with default name', async () => { + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await expect(renameInput).not.toHaveValue(''); + }); + + await test.step('Verify workspace is NOT yet created on filesystem', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs).toHaveLength(0); + }); + + await test.step('Type workspace name and press Enter to confirm', async () => { + const renameInput = page.locator('.workspace-name-input'); + await renameInput.fill('My Test Workspace'); + await renameInput.press('Enter'); + }); + + await test.step('Verify workspace created successfully', async () => { + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('My Test Workspace', { timeout: 5000 }); + }); + + await test.step('Verify workspace folder exists on filesystem', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs.length).toBe(1); + + const ymlPath = path.join(wsLocation, wsDirs[0], 'workspace.yml'); + const config = yaml.load(fs.readFileSync(ymlPath, 'utf8')) as WorkspaceConfig; + expect(config?.info?.name).toBe('My Test Workspace'); + expect(config?.info?.type).toBe('workspace'); + }); + + await closeElectronApp(app); + }); + + test('should create workspace via inline rename and click check icon', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-check'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Click "Create workspace" and fill name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Check Icon Workspace'); + }); + + await test.step('Click the check icon to confirm', async () => { + await page.locator('.inline-action-btn.save').click(); + }); + + await test.step('Verify workspace created', async () => { + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Check Icon Workspace', { timeout: 5000 }); + }); + + await test.step('Verify filesystem', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs.length).toBe(1); + expect(fs.existsSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'))).toBe(true); + }); + + await closeElectronApp(app); + }); + + test('should create workspace via inline rename and click outside', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-outside'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create workspace and fill name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Click Outside Workspace'); + }); + + await test.step('Click outside the rename container to confirm', async () => { + await page.locator('.app-titlebar').click({ position: { x: 500, y: 10 } }); + }); + + await test.step('Verify workspace created', async () => { + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Click Outside Workspace', { timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Cancel/Discard Flow', () => { + test('should discard temp workspace when pressing Escape', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-escape'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start workspace creation', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Press Escape to cancel', async () => { + await page.locator('.workspace-name-input').press('Escape'); + }); + + await test.step('Verify switched back to default workspace', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await test.step('Verify no workspace folder created on filesystem', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs).toHaveLength(0); + }); + + await closeElectronApp(app); + }); + + test('should discard temp workspace when clicking X icon', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-x'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start workspace creation', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Click the X icon to cancel', async () => { + await page.locator('.inline-action-btn.cancel').click(); + }); + + await test.step('Verify switched back to default workspace', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + + test('should discard temp workspace when clicking outside with empty name', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-outside-empty'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start workspace creation and clear the name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill(''); + }); + + await test.step('Click outside to trigger cancel with empty name', async () => { + await page.locator('.app-titlebar').click({ position: { x: 500, y: 10 } }); + }); + + await test.step('Verify switched back to default workspace', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Advanced Modal Flow', () => { + test('should create workspace via advanced modal with custom location', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-modal'); + const customLocation = await createTmpDir('custom-ws-location'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start inline creation and click settings icon to open advanced modal', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + + // Settings gear icon should be visible during creation + const cogBtn = page.locator('.cog-btn'); + await expect(cogBtn).toBeVisible(); + await cogBtn.click(); + }); + + await test.step('Fill in the advanced modal form with custom location', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + + // Fill workspace name + await modal.locator('#workspace-name').fill('Advanced Workspace'); + + // Wait for folder name section to appear + await page.waitForTimeout(300); + + // The location input is read-only and Formik-controlled — .fill() won't update + // Formik state. Stub the dialog so the browse() callback sets the custom location. + await app.evaluate( + ({ dialog }, targetPath: string) => { + (dialog as any).showOpenDialog = () => + Promise.resolve({ canceled: false, filePaths: [targetPath] }); + }, + customLocation + ); + // Click the location input to trigger browse() which calls showOpenDialog + await modal.locator('#workspace-location').click(); + // Verify location was set + await expect(modal.locator('#workspace-location')).toHaveValue(customLocation, { timeout: 5000 }); + }); + + await test.step('Submit the form', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.getByRole('button', { name: 'Create Workspace' }).click(); + }); + + await test.step('Verify workspace created', async () => { + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Advanced Workspace', { timeout: 5000 }); + }); + + await test.step('Verify filesystem at custom location (NOT default location)', async () => { + // Workspace should be at customLocation, not wsLocation + const customDirs = findCreatedWorkspaceDirs(customLocation); + expect(customDirs.length).toBe(1); + + const config = yaml.load( + fs.readFileSync(path.join(customLocation, customDirs[0], 'workspace.yml'), 'utf8') + ) as WorkspaceConfig; + expect(config?.info?.name).toBe('Advanced Workspace'); + + // No workspace at the default location + const defaultDirs = findCreatedWorkspaceDirs(wsLocation); + expect(defaultDirs).toHaveLength(0); + }); + + await test.step('Verify inline rename input is cleared after modal creation', async () => { + await expect(page.locator('.workspace-name-input')).not.toBeVisible(); + }); + + await closeElectronApp(app); + }); + + test('should create workspace via advanced modal at default location', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-modal-default'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start inline creation and open advanced modal', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + await page.locator('.cog-btn').click(); + }); + + await test.step('Fill name and keep default location', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + await modal.locator('#workspace-name').fill('Default Loc Workspace'); + await page.waitForTimeout(300); + }); + + await test.step('Submit the form', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.getByRole('button', { name: 'Create Workspace' }).click(); + }); + + await test.step('Verify workspace created at default location', async () => { + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Default Loc Workspace', { timeout: 5000 }); + + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs.length).toBe(1); + + const config = yaml.load( + fs.readFileSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'), 'utf8') + ) as WorkspaceConfig; + expect(config?.info?.name).toBe('Default Loc Workspace'); + }); + + await closeElectronApp(app); + }); + + test('should cancel advanced modal and discard temp workspace', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-modal-cancel'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start inline creation and open advanced modal', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + await page.locator('.cog-btn').click(); + }); + + await test.step('Cancel the advanced modal', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + await modal.getByRole('button', { name: 'Cancel' }).click(); + }); + + await test.step('Verify temp workspace discarded and back to default', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + await expect(page.locator('.workspace-name-input')).not.toBeVisible(); + }); + + await closeElectronApp(app); + }); + + test('should show validation error for empty name in modal', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-modal-empty'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start inline creation and open advanced modal', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + await page.locator('.cog-btn').click(); + }); + + await test.step('Clear name and try to submit', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + + // Ensure name field is empty + await modal.locator('#workspace-name').fill(''); + await modal.getByRole('button', { name: 'Create Workspace' }).click(); + }); + + await test.step('Verify validation error appears and modal stays open', async () => { + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await expect(modal).toBeVisible(); + const error = modal.locator('.text-red-500'); + await expect(error.first()).toBeVisible({ timeout: 2000 }); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Workspace Name Display', () => { + test('should show correct name in title bar dropdown after creation', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-display'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create a workspace with specific name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Display Test WS'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify name in title bar', async () => { + await expect(page.getByTestId('workspace-name')).toHaveText('Display Test WS', { timeout: 5000 }); + }); + + await test.step('Verify name in title bar dropdown', async () => { + await page.locator('.workspace-name-container').click(); + const wsItem = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Display Test WS' }); + await expect(wsItem.first()).toBeVisible(); + }); + + await closeElectronApp(app); + }); + + test('should persist workspace name after app restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('create-ws-name-persist'); + const wsLocation = await createTmpDir('ws-location-persist'); + + // First launch: create workspace + const app1 = await launchElectronApp({ userDataPath, initUserDataPath, templateVars: { wsLocation } }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create workspace', async () => { + await page1.locator('.workspace-name-container').click(); + await page1.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page1.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Persisted WS'); + await renameInput.press('Enter'); + await expect(page1.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + }); + + await closeElectronApp(app1); + + // Second launch: verify name persists (reuse same userDataPath) + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Verify workspace name persisted', async () => { + await page2.locator('.workspace-name-container').click(); + const wsItem = page2.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Persisted WS' }); + await expect(wsItem.first()).toBeVisible({ timeout: 5000 }); + }); + + await closeElectronApp(app2); + }); + }); + + test.describe('Edge Cases', () => { + test('should handle creating multiple workspaces sequentially', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-multiple'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create first workspace', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Workspace One'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Workspace One', { timeout: 5000 }); + }); + + await test.step('Create second workspace', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Workspace Two'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Workspace Two', { timeout: 5000 }); + }); + + await test.step('Verify both workspaces exist in dropdown', async () => { + await page.locator('.workspace-name-container').click(); + const wsOne = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Workspace One' }); + const wsTwo = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Workspace Two' }); + await expect(wsOne.first()).toBeVisible(); + await expect(wsTwo.first()).toBeVisible(); + }); + + await test.step('Verify both workspace folders on filesystem', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs.length).toBe(2); + }); + + await closeElectronApp(app); + }); + + test('should handle creating then cancelling then creating again', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-cancel-retry'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start creation and cancel with Escape', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + await page.locator('.workspace-name-input').press('Escape'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await test.step('Create again successfully', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Retry Workspace'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Retry Workspace', { timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + + test('should handle workspace name with special characters', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-special'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create workspace with special characters in name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('My API & Testing (v2)'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('My API & Testing (v2)', { timeout: 5000 }); + }); + + await test.step('Verify workspace name stored correctly in workspace.yml', async () => { + const wsDirs = findCreatedWorkspaceDirs(wsLocation); + expect(wsDirs.length).toBe(1); + + const config = yaml.load( + fs.readFileSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'), 'utf8') + ) as WorkspaceConfig; + expect(config?.info?.name).toBe('My API & Testing (v2)'); + }); + + await closeElectronApp(app); + }); + + test('should show validation error for empty name inline when pressing Enter', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-empty'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create workspace and clear name', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill(''); + }); + + await test.step('Press Enter with empty name - should show error', async () => { + await page.locator('.workspace-name-input').press('Enter'); + const error = page.locator('.workspace-error'); + await expect(error).toBeVisible({ timeout: 2000 }); + await expect(error).toContainText('required'); + }); + + await test.step('Verify still in rename mode (not discarded)', async () => { + await expect(page.locator('.workspace-name-input')).toBeVisible(); + }); + + await closeElectronApp(app); + }); + + test('should not show settings/cog icon when renaming an existing workspace', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-no-cog'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create a workspace first', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + + // During creation, the cog button should be visible + await expect(page.locator('.cog-btn')).toBeVisible(); + + await renameInput.fill('Existing WS'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Rename existing workspace - cog should NOT be visible', async () => { + // Use workspace actions dropdown to start rename + const actionsIcon = page.locator('.workspace-actions-trigger'); + await actionsIcon.click(); + await page.locator('.dropdown-item').filter({ hasText: 'Rename' }).click(); + + // Inline rename input should appear + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + + // Cog button should NOT be visible for existing workspace rename + await expect(page.locator('.cog-btn')).not.toBeVisible(); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Workspace Switching After Creation', () => { + test('should switch between created workspace and default workspace', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-switch'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create a new workspace', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + const renameInput = page.locator('.workspace-name-input'); + await expect(renameInput).toBeVisible({ timeout: 5000 }); + await renameInput.fill('Switchable WS'); + await renameInput.press('Enter'); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('workspace-name')).toHaveText('Switchable WS', { timeout: 5000 }); + }); + + await test.step('Switch to default workspace via dropdown', async () => { + await page.locator('.workspace-name-container').click(); + const defaultWs = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'My Workspace' }); + await defaultWs.first().click(); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 }); + }); + + await test.step('Switch back to created workspace', async () => { + await page.locator('.workspace-name-container').click(); + const createdWs = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Switchable WS' }); + await createdWs.first().click(); + await expect(page.getByTestId('workspace-name')).toHaveText('Switchable WS', { timeout: 5000 }); + }); + + await closeElectronApp(app); + }); + }); + + test.describe('Temp Workspace Isolation', () => { + test('should exclude temp workspace from duplicate name validation in advanced modal', async ({ launchElectronApp, createTmpDir }) => { + const wsLocation = await createTmpDir('ws-location-no-temp'); + + const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Start creation but do not confirm', async () => { + await page.locator('.workspace-name-container').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click(); + await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Open advanced modal and verify temp workspace name is not a conflict', async () => { + await page.locator('.cog-btn').click(); + + const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + + // Fill the same name as temp workspace — should NOT show "already exists" error + // since isCreating workspaces are excluded from validation + await modal.locator('#workspace-name').fill('Untitled Workspace'); + await page.waitForTimeout(500); + + const errorText = modal.locator('.text-red-500'); + const hasError = await errorText.isVisible().catch(() => false); + if (hasError) { + const errorContent = await errorText.textContent(); + expect(errorContent).not.toContain('already exists'); + } + }); + + await closeElectronApp(app); + }); + }); +}); diff --git a/tests/workspace/create-workspace/init-user-data/preferences.json b/tests/workspace/create-workspace/init-user-data/preferences.json new file mode 100644 index 000000000..717f19028 --- /dev/null +++ b/tests/workspace/create-workspace/init-user-data/preferences.json @@ -0,0 +1,11 @@ +{ + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + }, + "general": { + "defaultLocation": "{{wsLocation}}" + } + } +}