diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts index 0f4a1d29..f55222e6 100644 --- a/src/main/frontend/app/actions/navigationActions.ts +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -1,6 +1,7 @@ import { type NavigateFunction } from 'react-router' import useTabStore from '~/stores/tab-store' import useEditorTabStore from '~/stores/editor-tab-store' +import { getBaseName } from '~/utils/path-utils' export function openInStudio( navigate: NavigateFunction, @@ -42,7 +43,7 @@ export function openInEditorAtElement( { subtype, filepath, name }: { subtype: string; filepath: string; name?: string }, ) { const editorStore = useEditorTabStore.getState() - const fileName = filepath.split(/[/\\]/).pop() ?? filepath + const fileName = getBaseName(filepath) if (!editorStore.getTab(filepath)) { editorStore.setTabData(filepath, { diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx index dcc05bc8..7c6ac003 100644 --- a/src/main/frontend/app/components/directory-picker/directory-picker.tsx +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -5,6 +5,7 @@ import { filesystemService } from '~/services/filesystem-service' import type { FilesystemEntry } from '~/types/filesystem.types' import { ApiError } from '~/utils/api' import { useDirectoryWatcher } from '~/hooks/use-file-watcher' +import { normalizePath } from '~/utils/path-utils' import Button from '../inputs/button' import CloseButton from '../inputs/close-button' @@ -36,9 +37,9 @@ export default function DirectoryPicker({ setIsCreatingFolder(false) try { const result = await filesystemService.browse(path) - setEntries(result.entries) - setCurrentPath(result.resolvedPath) - setParentPath(result.parentPath) + setEntries(result.entries.map((entry) => ({ ...entry, path: normalizePath(entry.path) }))) + setCurrentPath(normalizePath(result.resolvedPath)) + setParentPath(normalizePath(result.parentPath)) } catch (error) { if (error instanceof ApiError && error.httpCode === 403) { setError('Access denied') diff --git a/src/main/frontend/app/components/file-structure/name-input-dialog.tsx b/src/main/frontend/app/components/file-structure/name-input-dialog.tsx index 50871f76..2b090fcd 100644 --- a/src/main/frontend/app/components/file-structure/name-input-dialog.tsx +++ b/src/main/frontend/app/components/file-structure/name-input-dialog.tsx @@ -22,6 +22,11 @@ export const CONFIGURATION_NAME_PATTERNS: Record = { export const FOLDER_OR_ADAPTER_NAME_PATTERNS: Record = BASE_NAME_PATTERNS +export const PROJECT_NAME_PATTERNS: Record = { + ...BASE_NAME_PATTERNS, + 'Cannot have a file extension': /^(?!.*\.[^.\\/]+$).*$/, +} + interface NameInputDialogProps { title: string initialValue?: string diff --git a/src/main/frontend/app/components/file-structure/studio-file-structure.tsx b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx index 1fd6f690..bb8be0be 100644 --- a/src/main/frontend/app/components/file-structure/studio-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx @@ -47,8 +47,7 @@ function studioTabItemId(activeTab: string, dataProvider: StudioFilesDataProvide const lastSep = rest.lastIndexOf('::') const adapterName = lastSep === -1 ? rest : rest.slice(0, lastSep) const position = lastSep === -1 ? '0' : rest.slice(lastSep + 2) - const rootPath = dataProvider.getRootPath().replace(/[/\\]$/, '') - return `${toTreeItemId(configPath, rootPath)}/${adapterName}::${position}` + return `${toTreeItemId(configPath, dataProvider.getRootPath())}/${adapterName}::${position}` } function getItemTitle(item: TreeItem): string { diff --git a/src/main/frontend/app/components/file-structure/tree-utilities.ts b/src/main/frontend/app/components/file-structure/tree-utilities.ts index fa58e30e..58a3476c 100644 --- a/src/main/frontend/app/components/file-structure/tree-utilities.ts +++ b/src/main/frontend/app/components/file-structure/tree-utilities.ts @@ -5,6 +5,7 @@ import MailIcon from '../../../icons/solar/Mailbox.svg?react' import FolderIcon from '../../../icons/solar/Folder.svg?react' import type { FileTreeNode } from '~/types/filesystem.types' import type { TreeRef } from 'react-complex-tree' +import { relativeTo } from '~/utils/path-utils' export function getListenerIcon(listenerType: string | null) { if (!listenerType) return CodeIcon @@ -31,8 +32,8 @@ export function getAncestorIds(itemId: string): string[] { } export function toTreeItemId(absolutePath: string, rootPath: string): string { - const relativePath = absolutePath.slice(rootPath.length).replace(/^[/\\]/, '') - return `root/${relativePath.split(/[/\\]/).join('/')}` + const relativePath = relativeTo(rootPath, absolutePath) + return relativePath ? `root/${relativePath}` : 'root' } export function isVisibleInTree(itemId: string | null, expandedItems: string[]): boolean { diff --git a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts index 64273cd0..6cde38b9 100644 --- a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts @@ -12,6 +12,7 @@ import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS, } from '~/components/file-structure/name-input-dialog' +import { getBaseName, getParentPath, joinPath, relativeTo } from '~/utils/path-utils' export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' | 'file' @@ -56,7 +57,7 @@ export function detectItemType(data: StudioItemData, isFolder?: boolean): Studio if (isFolder) return 'folder' - const lastSegment = path.split(/[/\\]/).at(-1) ?? path + const lastSegment = getBaseName(path) if (lastSegment.includes('.')) return 'file' return 'folder' } @@ -68,11 +69,6 @@ export function getItemName(data: StudioItemData): string { return 'Unnamed' } -function getParentDir(filePath: string): string { - const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) - return lastSep > 0 ? filePath.slice(0, lastSep) : filePath -} - function ensureXmlExtension(name: string): string { if (name.endsWith('.xml')) return name return `${name}.xml` @@ -90,12 +86,12 @@ export function resolveItemPaths( if (itemType === 'adapter') { const configPath = (data as StudioAdapterData).configPath - return { path: configPath, folderPath: getParentDir(configPath) } + return { path: configPath, folderPath: getParentPath(configPath) } } const folderData = data as StudioFolderData if (itemType === 'configuration' || itemType === 'file') { - return { path: folderData.path, folderPath: getParentDir(folderData.path) } + return { path: folderData.path, folderPath: getParentPath(folderData.path) } } return { path: folderData.path, folderPath: folderData.path } @@ -176,12 +172,8 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { - const rootPath = dataProvider.getRootPath().replace(/[/\\]$/, '') - const folderPath = menu.folderPath.replace(/[/\\]$/, '') - const relativePath = - folderPath === rootPath - ? fileName - : `${folderPath.slice(rootPath.length + 1).replaceAll('\\', '/')}/${fileName}` + const relativeFolder = relativeTo(dataProvider.getRootPath(), menu.folderPath) + const relativePath = relativeFolder ? joinPath(relativeFolder, fileName) : fileName await createConfigurationFile(projectName, relativePath) await dataProvider.reloadDirectory('root') } catch (error) { @@ -268,7 +260,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon clearConfigurationFileCache(projectName, menu.path) useTabStore.getState().renameTabsForConfig(menu.path, newPath) } else { - await renameFile(projectName, menu.path, `${getParentDir(menu.path)}/${newName}`) + await renameFile(projectName, menu.path, `${getParentPath(menu.path)}/${newName}`) } await dataProvider.reloadDirectory('root') } catch (error) { diff --git a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx index f8716715..78272f07 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -7,6 +7,7 @@ import CloseButton from '~/components/inputs/close-button' import Input from '~/components/inputs/input' import DirectoryPicker from '~/components/directory-picker/directory-picker' import { fetchProject } from '~/services/project-service' +import { containsPathSeparator, joinPath, relativeTo } from '~/utils/path-utils' interface AddConfigurationModalProperties { isOpen: boolean @@ -47,12 +48,21 @@ export default function AddConfigurationModal({ setLoading(false) return } + + if (containsPathSeparator(configname) || configname.includes('..')) { + setError(String.raw`Filename cannot contain "/", "\" or ".."`) + setLoading(false) + return + } // Ensure .xml suffix if (!configname.toLowerCase().endsWith('.xml')) { configname = `${configname}.xml` } - await createConfigurationFile(currentConfiguration.name, `${rootLocationName}/${configname}`) + const relativeFolder = relativeTo(currentConfiguration.rootPath, rootLocationName) + const relativePath = relativeFolder ? joinPath(relativeFolder, configname) : configname + + await createConfigurationFile(currentConfiguration.name, relativePath) const updatedProject = await fetchProject(currentConfiguration.name) setProject(updatedProject) onSuccess?.() @@ -83,10 +93,13 @@ export default function AddConfigurationModal({ setIsOpenPickerOpen(false) } + const trimmedFilename = filename.trim() + const filenameHasInvalidChars = containsPathSeparator(trimmedFilename) || trimmedFilename.includes('..') + const isFilenameValid = trimmedFilename.length > 0 && !filenameHasInvalidChars + const displayFilename = (() => { - const trimmed = filename.trim() - if (!trimmed) return '' - return trimmed.toLowerCase().endsWith('.xml') ? trimmed : `${trimmed}.xml` + if (!trimmedFilename) return '' + return trimmedFilename.toLowerCase().endsWith('.xml') ? trimmedFilename : `${trimmedFilename}.xml` })() return ( @@ -120,19 +133,25 @@ export default function AddConfigurationModal({ -
- setFilename(event.target.value)} - placeholder="Choose a filename" - aria-label="configuration filename" - /> +
+
+ setFilename(event.target.value)} + placeholder="Choose a filename" + aria-label="configuration filename" + /> + .xml +
+ {filenameHasInvalidChars && ( +

{String.raw`Filename cannot contain "/", "\" or ".."`}

+ )}
- diff --git a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx index 3ff69d33..2b18e5c0 100644 --- a/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-file-tile.tsx @@ -7,6 +7,7 @@ import IconButton from '~/components/inputs/icon-button' import IconLabelButton from '~/components/inputs/icon-label-button' import ConfirmDeleteDialog from '~/components/file-structure/confirm-delete-dialog' import { useState } from 'react' +import { getBaseName } from '~/utils/path-utils' interface ConfigurationFileTileProperties { filepath: string @@ -29,7 +30,7 @@ export default function ConfigurationFileTile({ } const handleOpenInEditor = () => { - const fileName = relativePath.split(/[/\\]/).pop() + const fileName = getBaseName(relativePath) if (!fileName) return openInEditor(fileName, filepath, navigate) } @@ -82,7 +83,7 @@ export default function ConfigurationFileTile({ {showDeleteDialog && ( setShowDeleteDialog(false)} onConfirm={handleConfirmDelete} diff --git a/src/main/frontend/app/routes/configurations/configuration-overview.tsx b/src/main/frontend/app/routes/configurations/configuration-overview.tsx index b415534c..38a9f40c 100644 --- a/src/main/frontend/app/routes/configurations/configuration-overview.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-overview.tsx @@ -11,7 +11,7 @@ import type { FileTreeNode } from '~/types/filesystem.types' import { fetchProjectTree } from '~/services/file-tree-service' import Button from '~/components/inputs/button' import Search from '~/components/search/search' -import { normalizePath, toRelativePath } from '~/utils/path-utils' +import { relativeTo } from '~/utils/path-utils' interface ConfigurationFile { path: string @@ -19,25 +19,6 @@ interface ConfigurationFile { adapterNames: string[] } -function findConfigurationsDir(node: FileTreeNode | undefined | null): FileTreeNode | null { - if (!node || !node.path) return null - - const normalizedPath = normalizePath(node.path) - - if (node.type === 'DIRECTORY' && normalizedPath.endsWith(`/src/main/configurations/${node.name}`)) { - return node - } - - if (!node.children) return null - - for (const child of node.children) { - const found = findConfigurationsDir(child) - if (found) return found - } - - return null -} - function collectXmlFiles(node: FileTreeNode | undefined | null): FileTreeNode[] { let result: FileTreeNode[] = [] if (!node) return result @@ -61,7 +42,6 @@ export default function ConfigurationOverview() { const [showModal, setShowModal] = useState(false) const [tree, setTree] = useState(null) const [isLoading, setIsLoading] = useState(false) - const [configurationsDir, setConfigurationsDir] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState(searchQuery) @@ -91,13 +71,6 @@ export default function ConfigurationOverview() { return () => controller.abort() }, [loadTree]) - useEffect(() => { - if (tree) { - const configDir = findConfigurationsDir(tree) - setConfigurationsDir(configDir) - } - }, [tree]) - const handleConfigAdded = useCallback(() => { setShowModal(false) loadTree() @@ -112,12 +85,9 @@ export default function ConfigurationOverview() { const configFiles = useMemo(() => { if (!tree || !currentConfigurationProject) return [] - const configurationDirectory = findConfigurationsDir(tree) - if (!configurationDirectory) return [] - - const xmlFiles = collectXmlFiles(configurationDirectory) + const xmlFiles = collectXmlFiles(tree) return xmlFiles.map((file) => { - const relativePath = toRelativePath(file.path, `${configurationDirectory.path}/`) ?? file.name + const relativePath = relativeTo(tree.path, file.path) || file.name return { ...file, relativePath, path: file.path } }) }, [tree, currentConfigurationProject]) @@ -208,7 +178,7 @@ export default function ConfigurationOverview() { onClose={() => setShowModal(false)} onSuccess={handleConfigAdded} currentConfiguration={currentConfigurationProject} - configurationsDirPath={configurationsDir?.path ?? ''} + configurationsDirPath={tree?.path ?? ''} />
) diff --git a/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx index 67ce0095..2fca6033 100644 --- a/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx @@ -4,6 +4,7 @@ import Button from '~/components/inputs/button' import CloseButton from '~/components/inputs/close-button' import Input from '~/components/inputs/input' import { filesystemService } from '~/services/filesystem-service' +import { joinPath } from '~/utils/path-utils' interface CloneProjectModalProperties { isLocal: boolean @@ -34,7 +35,13 @@ export default function CloneConfigurationModal({ setLocation(initialPath ?? '') }, [isLocal, initialPath]) - const repoName = repoUrl.split('/').pop()?.replace(/\.git$/i, '') + const repoName = repoUrl + .split('/') + .pop() + ?.replace(/\.git$/i, '') + + const targetName = repoName || 'cloned-project' + const targetPath = location ? joinPath(location, targetName) : targetName const handleClone = () => { if (!repoUrl.trim()) return @@ -110,14 +117,7 @@ export default function CloneConfigurationModal({ )} - {repoName && ( -

- Will clone to:{' '} - {isLocal - ? `${location}${location.includes('/') ? '/' : '\\'}${repoName}` - : `${location ? `${location}/` : ''}${repoName}`} -

- )} + {repoName &&

Will clone to: {targetPath}

}