mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
- Added validation for folder and file names to ensure they are not empty and conform to naming rules. - Display error messages using toast notifications for invalid names.
467 lines
17 KiB
JavaScript
467 lines
17 KiB
JavaScript
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|
import { useSelector, useDispatch } from 'react-redux';
|
|
import Modal from 'components/Modal';
|
|
import SearchInput from 'components/SearchInput';
|
|
import Button from 'ui/Button';
|
|
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
|
|
import filter from 'lodash/filter';
|
|
import toast from 'react-hot-toast';
|
|
import StyledWrapper from './StyledWrapper';
|
|
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
|
|
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
|
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
|
import { newFolder, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
|
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
|
import { resolveRequestFilename } from 'utils/common/platform';
|
|
import path from 'utils/common/path';
|
|
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
|
|
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
|
import { itemSchema } from '@usebruno/schema';
|
|
import { uuid } from 'utils/common';
|
|
import { formatIpcError } from 'utils/common/error';
|
|
|
|
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
|
|
const dispatch = useDispatch();
|
|
|
|
const latestCollection = useSelector((state) =>
|
|
collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null
|
|
);
|
|
const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp;
|
|
|
|
const item = itemProp;
|
|
const collection = collectionProp;
|
|
|
|
const handleClose = () => {
|
|
if (onClose) {
|
|
onClose();
|
|
return;
|
|
}
|
|
// Remove from Redux array
|
|
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
|
|
};
|
|
const [requestName, setRequestName] = useState(item?.name || '');
|
|
const [searchText, setSearchText] = useState('');
|
|
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
|
|
const [showFilesystemName, setShowFilesystemName] = useState(false);
|
|
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
|
|
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
|
|
const newFolderInputRef = useRef(null);
|
|
|
|
const {
|
|
currentFolders,
|
|
breadcrumbs,
|
|
selectedFolderUid,
|
|
navigateIntoFolder,
|
|
navigateToRoot,
|
|
navigateToBreadcrumb,
|
|
getCurrentParentFolder,
|
|
getCurrentSelectedFolder,
|
|
reset,
|
|
isAtRoot
|
|
} = useCollectionFolderTree(collection?.uid);
|
|
|
|
const resetForm = () => {
|
|
setRequestName(item.name || '');
|
|
setSearchText('');
|
|
reset();
|
|
setShowNewFolderInput(false);
|
|
setNewFolderName('');
|
|
setNewFolderDirectoryName('');
|
|
setShowFilesystemName(false);
|
|
setIsEditingFolderFilename(false);
|
|
setPendingFolderNavigation(null);
|
|
};
|
|
|
|
useEffect(() => {
|
|
isOpen && item && resetForm();
|
|
}, [isOpen, item]);
|
|
|
|
useEffect(() => {
|
|
if (showNewFolderInput && newFolderInputRef.current) {
|
|
newFolderInputRef.current.focus();
|
|
}
|
|
}, [showNewFolderInput]);
|
|
|
|
// Auto-navigate into newly created folder when it appears in currentFolders
|
|
useEffect(() => {
|
|
if (pendingFolderNavigation) {
|
|
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
|
|
if (newFolder) {
|
|
navigateIntoFolder(newFolder.uid);
|
|
setPendingFolderNavigation(null);
|
|
}
|
|
}
|
|
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
|
|
|
|
const filteredFolders = useMemo(() => {
|
|
if (!searchText.trim()) {
|
|
return currentFolders;
|
|
}
|
|
const searchLower = searchText.toLowerCase();
|
|
return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower));
|
|
}, [currentFolders, searchText]);
|
|
|
|
const handleCancel = () => {
|
|
resetForm();
|
|
handleClose();
|
|
};
|
|
|
|
const handleConfirm = async () => {
|
|
if (!item || !collection || !latestItem) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { ipcRenderer } = window;
|
|
|
|
const selectedFolder = getCurrentSelectedFolder();
|
|
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
|
|
|
|
const trimmedName = requestName.trim();
|
|
if (!trimmedName || trimmedName.length === 0) {
|
|
toast.error('Request name is required');
|
|
return;
|
|
}
|
|
|
|
if (!validateName(trimmedName)) {
|
|
toast.error(validateNameError(trimmedName));
|
|
return;
|
|
}
|
|
|
|
const sanitizedFilename = sanitizeName(trimmedName);
|
|
|
|
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
|
|
itemToSave.name = sanitizedFilename;
|
|
delete itemToSave.draft;
|
|
|
|
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
|
|
await itemSchema.validate(transformedItem);
|
|
|
|
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
|
|
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
|
|
const targetPathname = path.join(targetDirname, targetFilename);
|
|
|
|
await ipcRenderer.invoke('renderer:save-transient-request', {
|
|
sourcePathname: item.pathname,
|
|
targetDirname,
|
|
targetFilename,
|
|
request: transformedItem,
|
|
format
|
|
});
|
|
|
|
// Add task to open the newly saved request when file watcher detects it
|
|
dispatch(
|
|
insertTaskIntoQueue({
|
|
uid: uuid(),
|
|
type: 'OPEN_REQUEST',
|
|
collectionUid: collection.uid,
|
|
itemPathname: targetPathname,
|
|
preview: false
|
|
})
|
|
);
|
|
|
|
dispatch(closeTabs({ tabUids: [item.uid] }));
|
|
|
|
dispatch({
|
|
type: 'collections/deleteItem',
|
|
payload: {
|
|
itemUid: item.uid,
|
|
collectionUid: collection.uid
|
|
}
|
|
});
|
|
|
|
toast.success('Request saved successfully');
|
|
handleClose();
|
|
} catch (err) {
|
|
toast.error(formatIpcError(err) || 'Failed to save request');
|
|
console.error('Error saving request:', err);
|
|
}
|
|
};
|
|
|
|
const handleShowNewFolder = () => {
|
|
setShowNewFolderInput(true);
|
|
setNewFolderName('');
|
|
setNewFolderDirectoryName('');
|
|
setShowFilesystemName(false);
|
|
setIsEditingFolderFilename(false);
|
|
};
|
|
|
|
const handleCancelNewFolder = () => {
|
|
setShowNewFolderInput(false);
|
|
setNewFolderName('');
|
|
setNewFolderDirectoryName('');
|
|
setShowFilesystemName(false);
|
|
setIsEditingFolderFilename(false);
|
|
};
|
|
|
|
const handleNewFolderNameChange = (value) => {
|
|
setNewFolderName(value);
|
|
if (!isEditingFolderFilename) {
|
|
setNewFolderDirectoryName(sanitizeName(value));
|
|
}
|
|
};
|
|
|
|
const handleDirectoryNameChange = (value) => {
|
|
setNewFolderDirectoryName(value);
|
|
setIsEditingFolderFilename(true);
|
|
};
|
|
|
|
const handleCreateNewFolder = async () => {
|
|
const trimmedFolderName = newFolderName.trim();
|
|
|
|
if (!trimmedFolderName) {
|
|
toast.error('Folder name is required');
|
|
return;
|
|
}
|
|
|
|
if (!validateName(trimmedFolderName)) {
|
|
toast.error(validateNameError(trimmedFolderName));
|
|
return;
|
|
}
|
|
|
|
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
|
|
const parentFolder = getCurrentParentFolder();
|
|
|
|
try {
|
|
await dispatch(newFolder(trimmedFolderName, directoryName, collection?.uid, parentFolder?.uid));
|
|
toast.success('New folder created!');
|
|
|
|
// Set pending navigation - useEffect will navigate when folder appears in state
|
|
setPendingFolderNavigation(directoryName);
|
|
handleCancelNewFolder();
|
|
} catch (err) {
|
|
const errorMessage = err?.message || 'An error occurred while adding the folder';
|
|
toast.error(errorMessage);
|
|
}
|
|
};
|
|
|
|
const handleFolderClick = (folderUid) => {
|
|
navigateIntoFolder(folderUid);
|
|
setSearchText('');
|
|
};
|
|
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<StyledWrapper>
|
|
<Modal
|
|
size="md"
|
|
title="Save Request"
|
|
handleCancel={handleCancel}
|
|
handleConfirm={handleConfirm}
|
|
confirmText="Save"
|
|
cancelText="Cancel"
|
|
hideFooter={true}
|
|
>
|
|
<div className="save-request-form">
|
|
<div className="form-section">
|
|
<label htmlFor="request-name" className="form-label">
|
|
Request name
|
|
</label>
|
|
<input
|
|
id="request-name"
|
|
type="text"
|
|
className="form-input textbox"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck="false"
|
|
value={requestName}
|
|
onChange={(e) => setRequestName(e.target.value)}
|
|
autoFocus={true}
|
|
onFocus={(e) => e.target.select()}
|
|
/>
|
|
</div>
|
|
|
|
<div className="collections-section">
|
|
<div className="collections-label">Save to Collections</div>
|
|
{collection && (
|
|
<div
|
|
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
|
|
onClick={!isAtRoot ? navigateToRoot : undefined}
|
|
>
|
|
<span>{collection.name}</span>
|
|
{breadcrumbs.length > 0 && (
|
|
<>
|
|
{breadcrumbs.map((breadcrumb, index) => (
|
|
<React.Fragment key={breadcrumb.uid}>
|
|
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
|
|
<span
|
|
className="collection-name-breadcrumb"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigateToBreadcrumb(index);
|
|
setSearchText('');
|
|
}}
|
|
>
|
|
{breadcrumb.name}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
</>
|
|
)}
|
|
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
|
|
</div>
|
|
)}
|
|
|
|
<div className="search-container">
|
|
<SearchInput
|
|
searchText={searchText}
|
|
setSearchText={setSearchText}
|
|
placeholder="Search for folder"
|
|
autoFocus={false}
|
|
/>
|
|
</div>
|
|
|
|
<div className="folder-list">
|
|
{filteredFolders.length > 0 || showNewFolderInput ? (
|
|
<ul className="folder-list-items">
|
|
{filteredFolders.map((folder) => (
|
|
<li
|
|
key={folder.uid}
|
|
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
|
|
onClick={() => handleFolderClick(folder.uid)}
|
|
>
|
|
<div className="folder-item-content">
|
|
<IconFolder size={16} strokeWidth={1.5} />
|
|
<span className="folder-item-name">{folder.name}</span>
|
|
</div>
|
|
<IconChevronRight size={16} strokeWidth={1.5} />
|
|
</li>
|
|
))}
|
|
{showNewFolderInput && (
|
|
<li className="new-folder-item">
|
|
<div className="new-folder-header">
|
|
<IconFolder size={16} strokeWidth={1.5} />
|
|
<label className="new-folder-header-label">
|
|
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
|
|
</label>
|
|
</div>
|
|
<div className="new-folder-input-row">
|
|
<input
|
|
ref={newFolderInputRef}
|
|
type="text"
|
|
className="new-folder-input"
|
|
placeholder="Untitled new folder"
|
|
value={newFolderName}
|
|
onChange={(e) => handleNewFolderNameChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCreateNewFolder();
|
|
} else if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
handleCancelNewFolder();
|
|
}
|
|
}}
|
|
/>
|
|
<div className="new-folder-actions">
|
|
<button
|
|
type="button"
|
|
className="new-folder-action-btn"
|
|
onClick={handleCancelNewFolder}
|
|
title="Cancel"
|
|
>
|
|
<IconX size={16} strokeWidth={1.5} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="new-folder-action-btn"
|
|
onClick={handleCreateNewFolder}
|
|
title="Create folder"
|
|
>
|
|
<IconCheck size={16} strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showFilesystemName && (
|
|
<div className="new-folder-filesystem-wrapper">
|
|
<label className="new-folder-filesystem-label">Name on filesystem</label>
|
|
<input
|
|
type="text"
|
|
className="new-folder-input"
|
|
value={newFolderDirectoryName}
|
|
onChange={(e) => handleDirectoryNameChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCreateNewFolder();
|
|
} else if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
handleCancelNewFolder();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className="new-folder-toggle-filesystem-btn"
|
|
onClick={() => {
|
|
setShowFilesystemName(!showFilesystemName);
|
|
setNewFolderDirectoryName(sanitizeName(newFolderName));
|
|
setIsEditingFolderFilename(false);
|
|
}}
|
|
>
|
|
{showFilesystemName ? (
|
|
<>
|
|
<IconEyeOff size={16} strokeWidth={1.5} />
|
|
<span>Hide filesystem name</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconEye size={16} strokeWidth={1.5} />
|
|
<span>Show filesystem name</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
) : (
|
|
<div className="folder-empty-state">
|
|
{searchText.trim() ? 'No folders found' : 'No folders available'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="custom-modal-footer">
|
|
<div className="footer-left">
|
|
{!showNewFolderInput && (
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
variant="ghost"
|
|
icon={<IconFolder size={16} strokeWidth={1.5} />}
|
|
onClick={handleShowNewFolder}
|
|
>
|
|
New Folder
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="footer-right">
|
|
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" color="primary" onClick={handleConfirm}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</StyledWrapper>
|
|
);
|
|
};
|
|
|
|
export default SaveTransientRequest;
|