diff --git a/src/main/frontend/app/components/file-structure/editor-data-provider.ts b/src/main/frontend/app/components/file-structure/editor-data-provider.ts index f04e564e..7e028cb1 100644 --- a/src/main/frontend/app/components/file-structure/editor-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/editor-data-provider.ts @@ -37,10 +37,8 @@ export default class EditorFilesDataProvider extends BaseFilesDataProvider { - this.data = {} - this.loadedDirectories.clear() - await this.fetchAndBuildTree() + public override async reloadDirectory(itemId: TreeItemIndex): Promise { + await super.reloadDirectory(itemId) await this.preloadExpandedItems() this.notifyListeners(Object.keys(this.data)) } diff --git a/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx b/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx index 50adc0d5..5de34c46 100644 --- a/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx +++ b/src/main/frontend/app/components/file-structure/file-tree-dialogs.tsx @@ -1,5 +1,5 @@ import ContextMenu from './context-menu' -import NameInputDialog, { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS } from './name-input-dialog' +import NameInputDialog from './name-input-dialog' import ConfirmDeleteDialog from './confirm-delete-dialog' import type { ContextMenuState, NameDialogState, DeleteTargetState } from './use-file-tree-context-menu' @@ -51,9 +51,7 @@ export default function FileTreeDialogs({ initialValue={nameDialog.initialValue} onSubmit={nameDialog.onSubmit} onCancel={onCloseNameDialog} - patterns={ - nameDialog.title.toLowerCase().includes('folder') ? FOLDER_OR_ADAPTER_NAME_PATTERNS : FILE_NAME_PATTERNS - } + patterns={nameDialog.patterns} /> )} diff --git a/src/main/frontend/app/components/file-structure/studio-context-menu.tsx b/src/main/frontend/app/components/file-structure/studio-context-menu.tsx index 9a6e14b4..f93ac035 100644 --- a/src/main/frontend/app/components/file-structure/studio-context-menu.tsx +++ b/src/main/frontend/app/components/file-structure/studio-context-menu.tsx @@ -31,7 +31,8 @@ export default function StudioContextMenu({ const showNewConfigurationAndNewFolder = itemType === 'root' || itemType === 'folder' const showNewAdapter = itemType === 'configuration' - const showRenameAndDelete = itemType === 'configuration' || itemType === 'adapter' || itemType === 'folder' + const showRenameAndDelete = + itemType === 'configuration' || itemType === 'adapter' || itemType === 'folder' || itemType === 'file' return createPortal(
void, requireSelection: boolean) => { - const { nodes, edges } = useFlowStore.getState() - if (nodes.some((n) => n.selected) || edges.some((e) => e.selected)) return - const itemId = selectedItemId ?? (requireSelection ? null : 'root') if (!itemId || (itemId === 'root' && requireSelection)) return @@ -110,7 +106,6 @@ export default function StudioFileStructure() { 'studio-explorer.new-folder': () => triggerExplorerAction(studioContextMenu.handleNewFolder, false), 'studio-explorer.rename': () => triggerExplorerAction(studioContextMenu.handleRename, true), 'studio-explorer.delete': () => triggerExplorerAction(studioContextMenu.handleDelete, true), - 'studio-explorer.delete-mac': () => triggerExplorerAction(studioContextMenu.handleDelete, true), }) useEffect(() => { @@ -352,7 +347,7 @@ export default function StudioFileStructure() { return (
studioContextMenu.openContextMenu(e, item.index)} > diff --git a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts index cf45d17c..503055c9 100644 --- a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts @@ -112,8 +112,8 @@ export function useFileTreeContextMenu({ (menuState?: ContextMenuState) => { const menu = resolveMenu(menuState) if (!menu || !projectName || !dataProvider) return - const parentPath = menu.path - const parentItemId = menu.itemId + const parentPath = menu.isFolder ? menu.path : buildNewPath(menu.path, '').slice(0, -1) + const parentItemId = menu.isFolder ? menu.itemId : getParentItemId(menu.itemId) closeContextMenu() setNameDialog({ @@ -142,8 +142,8 @@ export function useFileTreeContextMenu({ (menuState?: ContextMenuState) => { const menu = resolveMenu(menuState) if (!menu || !projectName || !dataProvider) return - const parentPath = menu.path - const parentItemId = menu.itemId + const parentPath = menu.isFolder ? menu.path : buildNewPath(menu.path, '').slice(0, -1) + const parentItemId = menu.isFolder ? menu.itemId : getParentItemId(menu.itemId) closeContextMenu() setNameDialog({ @@ -179,13 +179,13 @@ export function useFileTreeContextMenu({ if (newName === oldName) { setNameDialog(null) return - } else if (!ensureHasCorrectExtension(newName)) { + } else if (!menu.isFolder && !ensureHasCorrectExtension(newName)) { showErrorToast(`Filename must have one of the following extensions: ${ALLOWED_EXTENSIONS.join(', ')}`) return } try { - await renameFile(projectName, `${oldPath}`, `${oldPath}`.replace(oldName, newName)) + await renameFile(projectName, oldPath, buildNewPath(oldPath, newName)) clearConfigurationCache(projectName, oldPath) const newPath = buildNewPath(oldPath, newName) useTabStore.getState().renameTabsForConfig(oldPath, newPath) @@ -227,10 +227,11 @@ export function useFileTreeContextMenu({ useTabStore.getState().removeTabsForConfig(deleteTarget.path) useEditorTabStore.getState().refreshAllTabs() onAfterDelete?.(deleteTarget.path) - await dataProvider.reloadDirectory(deleteTarget.parentItemId) } catch (error) { showErrorToastFrom('Failed to delete', error) } + + await dataProvider.reloadDirectory(deleteTarget.parentItemId) setDeleteTarget(null) }, [deleteTarget, projectName, dataProvider, onAfterDelete]) 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 6da56be7..25c84686 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 @@ -9,10 +9,11 @@ import { showErrorToastFrom } from '~/components/toast' import type { StudioItemData, StudioFolderData, StudioAdapterData } from './studio-files-data-provider' import { CONFIGURATION_NAME_PATTERNS, + FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS, } from '~/components/file-structure/name-input-dialog' -export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' +export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' | 'file' export interface StudioContextMenuState { position: { x: number; y: number } @@ -42,10 +43,20 @@ export interface StudioDataProviderLike { getRootPath(): string } -export function detectItemType(data: StudioItemData): StudioItemType { +export function detectItemType(data: StudioItemData, isFolder?: boolean): StudioItemType { if (typeof data === 'string') return 'root' + if ('adapterName' in data) return 'adapter' - if ('path' in data && (data as StudioFolderData).path.endsWith('.xml')) return 'configuration' + + if (!('path' in data)) return 'folder' + + const path = (data as StudioFolderData).path + if (path.endsWith('.xml')) return 'configuration' + + if (isFolder) return 'folder' + + const lastSegment = path.split(/[/\\]/).at(-1) ?? path + if (lastSegment.includes('.')) return 'file' return 'folder' } @@ -62,7 +73,7 @@ function getParentDir(filePath: string): string { } function ensureXmlExtension(name: string): string { - if (name.includes('.')) return name + if (name.endsWith('.xml')) return name return `${name}.xml` } @@ -82,7 +93,7 @@ export function resolveItemPaths( } const folderData = data as StudioFolderData - if (itemType === 'configuration') { + if (itemType === 'configuration' || itemType === 'file') { return { path: folderData.path, folderPath: getParentDir(folderData.path) } } @@ -104,6 +115,12 @@ interface UseStudioContextMenuOptions { dataProvider: StudioDataProviderLike | null } +function getRenamePatterns(itemType: StudioItemType): Record { + if (itemType === 'folder' || itemType === 'adapter') return FOLDER_OR_ADAPTER_NAME_PATTERNS + if (itemType === 'file') return FILE_NAME_PATTERNS + return CONFIGURATION_NAME_PATTERNS +} + export function useStudioContextMenu({ projectName, dataProvider }: UseStudioContextMenuOptions) { const [contextMenu, setContextMenu] = useState(null) const [nameDialog, setNameDialog] = useState(null) @@ -119,7 +136,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon const item = await dataProvider.getTreeItem(itemId) if (!item) return - const itemType = detectItemType(item.data) + const itemType = detectItemType(item.data, item.isFolder) const name = getItemName(item.data) const { path, folderPath } = resolveItemPaths(item.data, itemType, dataProvider) @@ -157,7 +174,13 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { - await createConfiguration(projectName, `${menu.folderPath}/${fileName}`) + const rootPath = dataProvider.getRootPath().replace(/[/\\]$/, '') + const folderPath = menu.folderPath.replace(/[/\\]$/, '') + const relativePath = + folderPath === rootPath + ? fileName + : `${folderPath.slice(rootPath.length + 1).replaceAll('\\', '/')}/${fileName}` + await createConfiguration(projectName, relativePath) await dataProvider.reloadDirectory('root') } catch (error) { showErrorToastFrom('Failed to create configuration', error) @@ -234,12 +257,14 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon try { if (menu.itemType === 'adapter') { await renameAdapter(projectName, oldName, newName, menu.path) - } else { + } else if (menu.itemType === 'configuration' || menu.itemType === 'file') { const finalName = menu.itemType === 'configuration' ? ensureXmlExtension(newName) : newName - await renameFile(projectName, `${menu.path}/${oldName}`, `${menu.path}/${newName}`) + const newPath = `${menu.folderPath}/${finalName}` + await renameFile(projectName, menu.path, newPath) clearConfigurationCache(projectName, menu.path) - const newPath = `${getParentDir(menu.path)}/${finalName}` useTabStore.getState().renameTabsForConfig(menu.path, newPath) + } else { + await renameFile(projectName, menu.path, `${getParentDir(menu.path)}/${newName}`) } await dataProvider.reloadDirectory('root') } catch (error) { @@ -247,10 +272,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon } setNameDialog(null) }, - patterns: - menu.itemType === 'folder' || menu.itemType === 'adapter' - ? FOLDER_OR_ADAPTER_NAME_PATTERNS - : CONFIGURATION_NAME_PATTERNS, + patterns: getRenamePatterns(menu.itemType), }) }, [projectName, dataProvider, closeContextMenu], @@ -282,10 +304,10 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon clearConfigurationCache(projectName, deleteTarget.path) useTabStore.getState().removeTabsForConfig(deleteTarget.path) } - await dataProvider.reloadDirectory('root') } catch (error) { showErrorToastFrom('Failed to delete', error) } + await dataProvider.reloadDirectory('root') setDeleteTarget(null) }, [deleteTarget, projectName, dataProvider]) diff --git a/src/main/frontend/app/hooks/use-shortcut-listener.ts b/src/main/frontend/app/hooks/use-shortcut-listener.ts index 9644c17d..66577bed 100644 --- a/src/main/frontend/app/hooks/use-shortcut-listener.ts +++ b/src/main/frontend/app/hooks/use-shortcut-listener.ts @@ -60,9 +60,11 @@ export function useShortcutListener() { continue } - event.preventDefault() - shortcut.handler() - return + const result = shortcut.handler() + if (result !== false) { + event.preventDefault() + return + } } } diff --git a/src/main/frontend/app/hooks/use-shortcut.ts b/src/main/frontend/app/hooks/use-shortcut.ts index ee07ec82..322b790a 100644 --- a/src/main/frontend/app/hooks/use-shortcut.ts +++ b/src/main/frontend/app/hooks/use-shortcut.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' import { useShortcutStore } from '~/stores/shortcut-store' -type ShortcutHandlers = Record void> +type ShortcutHandlers = Record boolean | void> export function useShortcut(handlers: ShortcutHandlers) { const handlersRef = useRef(handlers) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index d0470c6d..51883109 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -23,7 +23,7 @@ import useFlowStore, { type FlowState } from '~/stores/flow-store' import { useShallow } from 'zustand/react/shallow' import { FlowConfig } from '~/routes/studio/canvas/flow.config' import { getElementTypeFromName } from '~/routes/studio/node-translator-module' -import { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { NodeContextMenuContext, useNodeContextMenu } from './node-context-menu-context' import StickyNoteComponent, { type StickyNote } from '~/routes/studio/canvas/nodetypes/sticky-note' import useTabStore, { type TabData } from '~/stores/tab-store' @@ -520,14 +520,15 @@ function FlowCanvas() { setChildParentId(null) } - const deleteSelection = useCallback(() => { - if (isEditing) return + const deleteSelection = useCallback((): boolean => { + if (isEditing) return false const { nodes, edges, setNodes, setEdges } = useFlowStore.getState() const selectedNodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) const hasSelection = selectedNodeIds.size > 0 || edges.some((e) => e.selected) - if (!hasSelection) return + if (!hasSelection) return false setNodes(nodes.filter((n) => !n.selected)) setEdges(edges.filter((e) => !e.selected && !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))) + return true }, [isEditing]) useShortcut({ @@ -539,7 +540,7 @@ function FlowCanvas() { 'studio.redo-alt': () => useFlowStore.getState().redo(), 'studio.group': () => handleGrouping(), 'studio.ungroup': () => handleUngroup(), - 'studio.save': () => saveFlow(), + 'studio.save': () => void saveFlow(), 'studio.close-context': () => closeEditNodeContextOnEscape(), 'studio.delete': () => deleteSelection(), }) diff --git a/src/main/frontend/app/stores/shortcut-store.ts b/src/main/frontend/app/stores/shortcut-store.ts index 0cbfc244..d8b67397 100644 --- a/src/main/frontend/app/stores/shortcut-store.ts +++ b/src/main/frontend/app/stores/shortcut-store.ts @@ -19,7 +19,7 @@ export interface ShortcutDefinition { modifiers?: PlatformValue allowInInput?: boolean displayOnly?: boolean - handler?: () => void + handler?: () => boolean | void } function capitalize(s: string): string { diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index 716ad2d0..ff32a9e9 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -7,6 +7,7 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectService; @@ -24,10 +25,12 @@ public class ConfigurationService { private final FileSystemStorage fileSystemStorage; private final ProjectService projectService; + private final FileTreeService fileTreeService; - public ConfigurationService(FileSystemStorage fileSystemStorage, ProjectService projectService) { + public ConfigurationService(FileSystemStorage fileSystemStorage, ProjectService projectService, FileTreeService fileTreeService) { this.fileSystemStorage = fileSystemStorage; this.projectService = projectService; + this.fileTreeService = fileTreeService; } public ConfigurationDTO getConfigurationContent(String projectName, String filepath) throws IOException, ApiException { @@ -74,11 +77,13 @@ public String addConfiguration(String projectName, String configurationName) thr throw new ApiException("Invalid configuration name: " + configurationName, HttpStatus.BAD_REQUEST); } + Files.createDirectories(filePath.getParent()); + String defaultXml = loadDefaultConfigurationXml(); Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(defaultXml); String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); fileSystemStorage.writeFile(filePath.toString(), updatedContent); - + fileTreeService.invalidateTreeCache(projectName); return updatedContent; } diff --git a/src/main/java/org/frankframework/flow/file/FileService.java b/src/main/java/org/frankframework/flow/file/FileService.java index ed8da074..17ccb2d5 100644 --- a/src/main/java/org/frankframework/flow/file/FileService.java +++ b/src/main/java/org/frankframework/flow/file/FileService.java @@ -9,6 +9,7 @@ import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; +import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -18,10 +19,12 @@ public class FileService { public static final String[] ALLOWED_EXTENSIONS = { "", ".xml", ".json", ".yaml", ".yml", ".properties" }; private final ProjectService projectService; private final FileSystemStorage fileSystemStorage; + private final FileTreeService fileTreeService; - public FileService(ProjectService projectService, FileSystemStorage fileSystemStorage) { + public FileService(ProjectService projectService, FileSystemStorage fileSystemStorage, @Lazy FileTreeService fileTreeService) { this.projectService = projectService; this.fileSystemStorage = fileSystemStorage; + this.fileTreeService = fileTreeService; } public FileDTO readFile(String projectName, String path) throws ApiException { @@ -56,7 +59,7 @@ public FileTreeNode createOrUpdateFile(String projectName, String path, String f throw new ApiException("Failed to write file: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } -// invalidateTreeCache(projectName); + fileTreeService.invalidateTreeCache(projectName); FileTreeNode node = new FileTreeNode(); node.setName(fileName); @@ -90,7 +93,8 @@ public FileTreeNode renameFile(String projectName, String oldPath, String newPat } catch (IOException exception) { throw new ApiException(exception.getMessage(), HttpStatus.NOT_ACCEPTABLE); } -// invalidateTreeCache(projectName); + + fileTreeService.invalidateTreeCache(projectName); boolean isDir = Files.isDirectory(absoluteNewPath); FileTreeNode node = new FileTreeNode(); @@ -107,7 +111,7 @@ public void deleteFile(String projectName, String path) throws ApiException { } catch (IOException exception) { throw new ApiException("Failed to delete file: " + exception.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } -// invalidateTreeCache(projectName); + fileTreeService.invalidateTreeCache(projectName); } public void validateWithinProject(String projectName, String path) throws ApiException { diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 9fd40cfd..f13c4902 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import org.frankframework.flow.exception.ApiException; +import org.frankframework.flow.file.FileTreeService; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; @@ -30,6 +31,9 @@ class ConfigurationServiceTest { @Mock private ProjectService projectService; + @Mock + private FileTreeService fileTreeService; + private ConfigurationService configurationService; @TempDir @@ -37,7 +41,7 @@ class ConfigurationServiceTest { @BeforeEach void setUp() { - configurationService = new ConfigurationService(fileSystemStorage, projectService); + configurationService = new ConfigurationService(fileSystemStorage, projectService, fileTreeService); } private void stubToAbsolutePath() { diff --git a/src/test/java/org/frankframework/flow/file/FileServiceTest.java b/src/test/java/org/frankframework/flow/file/FileServiceTest.java index 9c88e7ea..aab53017 100644 --- a/src/test/java/org/frankframework/flow/file/FileServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileServiceTest.java @@ -39,6 +39,9 @@ class FileServiceTest { @Mock private FileSystemStorage fileSystemStorage; + @Mock + private FileTreeService fileTreeService; + private FileService fileService; private Path tempProjectRoot; private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; @@ -46,7 +49,7 @@ class FileServiceTest { @BeforeEach public void setUp() throws IOException { tempProjectRoot = Files.createTempDirectory("flow_unit_test"); - fileService = new FileService(projectService, fileSystemStorage); + fileService = new FileService(projectService, fileSystemStorage, fileTreeService); } @AfterEach diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 0168fffb..14c0094f 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -33,13 +33,15 @@ public class FileTreeServiceTest { @Mock private FileSystemStorage fileSystemStorage; + @Mock private FileTreeService fileTreeService; + private Path tempProjectRoot; private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; @BeforeEach public void setUp() throws IOException { - FileService fileService = new FileService(projectService, fileSystemStorage); + FileService fileService = new FileService(projectService, fileSystemStorage, fileTreeService); tempProjectRoot = Files.createTempDirectory("flow_unit_test"); fileTreeService = new FileTreeService(projectService, fileSystemStorage, fileService); }