feat: implement temporary workspace creation and confirmation flow (#7462)

* feat: implement temporary workspace creation and confirmation flow

* fixes
This commit is contained in:
naman-bruno
2026-03-13 12:24:45 +05:30
committed by GitHub
parent c0a2d74789
commit ab8a730bc3
9 changed files with 983 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 && (
<CreateWorkspace onClose={handleAdvancedCreateClose} />
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="workspace-input-wrapper">
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{currentWorkspace?.isCreating && (
<button
className="cog-btn"
onMouseDown={(e) => e.preventDefault()}
onClick={handleOpenAdvancedCreate}
title="Advanced options"
>
<IconSettings size={13} strokeWidth={1.5} />
</button>
)}
</div>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
title={currentWorkspace?.isCreating ? 'Create' : 'Save'}
>
<IconCheck size={14} strokeWidth={2} />
</button>

View File

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

View File

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