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 33d4a087..f04e564e 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 @@ -19,8 +19,30 @@ export default class EditorFilesDataProvider extends BaseFilesDataProvider a.split('/').length - b.split('/').length) + for (const id of sortedIds) { + if (id === 'root') continue + const item = this.data[id] + if (!item || !item.isFolder) continue + await this.loadDirectory(id) + } + } + + public override async reloadDirectory(_itemId: TreeItemIndex): Promise { + this.data = {} + this.loadedDirectories.clear() await this.fetchAndBuildTree() + await this.preloadExpandedItems() + this.notifyListeners(Object.keys(this.data)) } private async fetchAndBuildTree() { @@ -86,7 +108,7 @@ export default class EditorFilesDataProvider extends BaseFilesDataProvider { const provider = new EditorFilesDataProvider(project.name) - await provider.init() + await provider.init(editorExpandedItems) if (isMounted) { setDataProvider(provider) @@ -151,7 +151,6 @@ export default function EditorFileStructure() { const item = await dataProvider.getTreeItem(itemId) if (!item) return - // Toggle expanded state managed by onExpandItem naturally if needed if (item.isFolder) { return } @@ -213,6 +212,7 @@ export default function EditorFileStructure() { const renderItemArrow = ({ item, context }: { item: TreeItem; context: TreeItemRenderContext }) => { if (!item.isFolder) return null + const Icon = context.isExpanded ? AltArrowDownIcon : AltArrowRightIcon const handleClick = (event: React.MouseEvent) => { @@ -220,7 +220,13 @@ export default function EditorFileStructure() { context.toggleExpandedState() } - return + return ( + ctxMenu.openContextMenu(mouseEvent, item.index)} + className="rct-tree-item-arrow-isFolder rct-tree-item-arrow fill-foreground" + /> + ) } const renderItemTitle = ({ @@ -238,6 +244,7 @@ export default function EditorFileStructure() { const titleLower = title.toLowerCase() let highlightedTitle: JSX.Element | string = title + if (searchTerm && titleLower.includes(searchLower)) { const parts = title.split(new RegExp(`(${searchTerm})`, 'gi')) highlightedTitle = ( 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 7cf540e8..7335fc5c 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 @@ -228,7 +228,11 @@ export default function StudioFileStructure() { } return ( - + mouseEvent.stopPropagation()} + className="rct-tree-item-arrow-isFolder rct-tree-item-arrow fill-foreground" + /> ) } diff --git a/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts index 2d13a370..5ae8ec5f 100644 --- a/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts @@ -46,6 +46,13 @@ export default class FilesDataProvider extends BaseFilesDataProvider { + this.data = {} + this.loadedDirectories.clear() + await this.loadRoot() + this.notifyListeners(Object.keys(this.data)) + } + private async loadRoot() { const tree = await fetchProjectTree(this.projectName) @@ -130,6 +137,24 @@ export default class FilesDataProvider extends BaseFilesDataProvider { - const storedName = getStoredProjectName() - if (!storedName) { + const rootPath = getStoredProjectRootPath() + if (!rootPath) { setRestoring(false) return } - fetchProject(storedName) - .then((fetched: Project) => { + openProject(rootPath) + .then((fetched) => { useProjectStore.getState().setProject(fetched) }) .catch(() => { - sessionStorage.removeItem('active-project-name') + useProjectStore.getState().clearProject() }) .finally(() => { setRestoring(false) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 5b1893ad..e8df909b 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -32,6 +32,7 @@ import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' import { useProjectStore } from '~/stores/project-store' import { clearConfigurationCache, fetchConfigurationCached, saveConfiguration } from '~/services/configuration-service' +import useEditorTabStore from '~/stores/editor-tab-store' import { cloneWithRemappedIds } from '~/utils/flow-utils' import { showErrorToast } from '~/components/toast' import clsx from 'clsx' @@ -106,18 +107,20 @@ function FlowCanvas() { const project = useProjectStore.getState().project const saveFlow = useCallback(async () => { - const flowData = reactFlowRef.current.toObject() + const { nodes: flowNodes, edges: flowEdges, viewport: flowViewport } = useFlowStore.getState() + const flowData = { nodes: flowNodes, edges: flowEdges, viewport: flowViewport } + const currentProject = useProjectStore.getState().project const activeTabKey = useTabStore.getState().activeTab const tabData = useTabStore.getState().getTab(activeTabKey) const configurationPath = tabData?.configurationPath const adapterName = tabData?.name const adapterPosition = tabData?.adapterPosition - if (!configurationPath || !adapterName || !project) return + if (!configurationPath || !adapterName || !currentProject) return setSaveStatus('saving') try { - const fullConfigXml = await fetchConfigurationCached(project.name, configurationPath) + const fullConfigXml = await fetchConfigurationCached(currentProject.name, configurationPath) const configDoc = new DOMParser().parseFromString(fullConfigXml, 'text/xml') const allAdapters = [...configDoc.querySelectorAll('Adapter, adapter')] @@ -134,7 +137,7 @@ function FlowCanvas() { const newAdapterXml = await exportFlowToXml( flowData, - project.name, + currentProject.name, configurationPath, adapterName, existingAdapterXml, @@ -148,8 +151,9 @@ function FlowCanvas() { const updatedConfigXml = new XMLSerializer().serializeToString(configDoc).replace(/^<\?xml[^?]*\?>\s*/, '') - await saveConfiguration(project.name, configurationPath, updatedConfigXml) - clearConfigurationCache(project.name, configurationPath) + await saveConfiguration(currentProject.name, configurationPath, updatedConfigXml) + clearConfigurationCache(currentProject.name, configurationPath) + useEditorTabStore.getState().refreshAllTabs() setSaveStatus('saved') if (savedTimerRef.current) clearTimeout(savedTimerRef.current) @@ -186,6 +190,17 @@ function FlowCanvas() { } }, [nodes, edges, scheduleAutoSave]) + useEffect(() => { + useNodeContextStore.getState().registerSaveFlow(async () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + autoSaveTimerRef.current = null + } + await saveFlow() + }) + return () => useNodeContextStore.getState().registerSaveFlow(null) + }, [saveFlow]) + const sourceInfoReference = useRef<{ nodeId: string | null handleId: string | null diff --git a/src/main/frontend/app/routes/studio/context/element-hover-card.tsx b/src/main/frontend/app/routes/studio/context/element-hover-card.tsx index ae29c696..0ec4095d 100644 --- a/src/main/frontend/app/routes/studio/context/element-hover-card.tsx +++ b/src/main/frontend/app/routes/studio/context/element-hover-card.tsx @@ -1,4 +1,4 @@ -import {useFFDoc, useJavadocTransform} from "@frankframework/doc-library-react"; +import { useFFDoc, useJavadocTransform } from '@frankframework/doc-library-react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { ElementDetails } from '~/types/ff-doc.types' diff --git a/src/main/frontend/app/routes/studio/context/node-context.tsx b/src/main/frontend/app/routes/studio/context/node-context.tsx index 7737dbe6..cd9dc821 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -232,6 +232,7 @@ export default function NodeContext({ setShowNodeContext(false) setParentId(null) setChildParentId(null) + void useNodeContextStore.getState().saveFlow?.() return } updateChild(parentNode.id, updatedChild) @@ -239,6 +240,7 @@ export default function NodeContext({ setShowNodeContext(false) setParentId(null) setChildParentId(null) + void useNodeContextStore.getState().saveFlow?.() return } @@ -250,6 +252,7 @@ export default function NodeContext({ setIsNewNode(false) setIsEditing(false) setShowNodeContext(false) + void useNodeContextStore.getState().saveFlow?.() return } setAttributes(nodeId.toString(), newAttributesObject) @@ -258,6 +261,7 @@ export default function NodeContext({ } setIsEditing(false) setShowNodeContext(false) + void useNodeContextStore.getState().saveFlow?.() } const canSaveRef = useRef(canSave) diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index c1d3e55e..c80dbea1 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -5,6 +5,12 @@ export async function fetchProjectTree(projectName: string, signal?: AbortSignal return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree/configurations`, { signal }) } +export async function fetchShallowConfigurationsTree(projectName: string, signal?: AbortSignal): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree/configurations?shallow=true`, { + signal, + }) +} + export async function fetchProjectRootTree(projectName: string, signal?: AbortSignal): Promise { return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree`, { signal }) } diff --git a/src/main/frontend/app/stores/node-context-store.ts b/src/main/frontend/app/stores/node-context-store.ts index 6206fc83..54c53fda 100644 --- a/src/main/frontend/app/stores/node-context-store.ts +++ b/src/main/frontend/app/stores/node-context-store.ts @@ -11,6 +11,7 @@ interface NodeContextStore { childParentId: string | null draggedName: string | null editingSubtype: string | null + saveFlow: (() => Promise) | null setNodeId: (nodeId: number) => void setAttributes: (attributes?: Record) => void setIsEditing: (value: boolean) => void @@ -21,6 +22,7 @@ interface NodeContextStore { setChildParentId: (id: string | null) => void setDraggedName: (name: string | null) => void setEditingSubtype: (subtype: string | null) => void + registerSaveFlow: (fn: (() => Promise) | null) => void } const useNodeContextStore = create((set) => ({ @@ -33,6 +35,7 @@ const useNodeContextStore = create((set) => ({ childParentId: null, draggedName: null, editingSubtype: null, + saveFlow: null, setNodeId: (nodeId) => set({ nodeId }), setAttributes: (attributes) => set({ attributes }), setIsEditing: (value) => set({ isEditing: value }), @@ -43,6 +46,7 @@ const useNodeContextStore = create((set) => ({ setChildParentId: (childParentId: string | null) => set({ childParentId }), setDraggedName: (draggedName) => set({ draggedName }), setEditingSubtype: (editingSubtype) => set({ editingSubtype }), + registerSaveFlow: (saveFlow) => set({ saveFlow }), })) export default useNodeContextStore diff --git a/src/main/frontend/app/stores/project-store.ts b/src/main/frontend/app/stores/project-store.ts index 1e5c4a75..1616362c 100644 --- a/src/main/frontend/app/stores/project-store.ts +++ b/src/main/frontend/app/stores/project-store.ts @@ -2,8 +2,9 @@ import { create } from 'zustand' import type { Project } from '~/types/project.types' import { useTreeStore } from '~/stores/tree-store' import useTabStore from '~/stores/tab-store' +import useEditorTabStore from '~/stores/editor-tab-store' -const SESSION_KEY = 'active-project-name' +const STORAGE_ROOT_PATH_KEY = 'active-project-root-path' interface ProjectStoreState { project?: Project @@ -15,22 +16,24 @@ export const useProjectStore = create((set) => ({ project: undefined, setProject: (project: Project) => { set((state) => { - if (state.project && state.project.name !== project.name) { + if (state.project?.name !== project.name) { useTreeStore.getState().clearExpandedItems() useTabStore.getState().clearTabs() + useEditorTabStore.getState().clearTabs() } return { project } }) - sessionStorage.setItem(SESSION_KEY, project.name) + localStorage.setItem(STORAGE_ROOT_PATH_KEY, project.rootPath) }, clearProject: () => { - sessionStorage.removeItem(SESSION_KEY) + localStorage.removeItem(STORAGE_ROOT_PATH_KEY) useTreeStore.getState().clearExpandedItems() useTabStore.getState().clearTabs() + useEditorTabStore.getState().clearTabs() set({ project: undefined }) }, })) -export function getStoredProjectName(): string | null { - return sessionStorage.getItem(SESSION_KEY) +export function getStoredProjectRootPath(): string | null { + return localStorage.getItem(STORAGE_ROOT_PATH_KEY) } diff --git a/src/main/frontend/app/stores/tab-store.ts b/src/main/frontend/app/stores/tab-store.ts index aa676097..a503de4c 100644 --- a/src/main/frontend/app/stores/tab-store.ts +++ b/src/main/frontend/app/stores/tab-store.ts @@ -20,6 +20,8 @@ interface TabStoreState { setActiveTab: (tabId: string | undefined) => void removeTab: (tabId: string) => void removeTabAndSelectFallback: (tabId: string) => void + removeTabsForConfig: (configPath: string) => void + renameTabsForConfig: (oldConfigPath: string, newConfigPath: string) => void clearTabs: () => void } @@ -55,6 +57,34 @@ const useTabStore = create()( activeTab: remainingKeys.includes(state.activeTab) ? state.activeTab : (remainingKeys.at(-1) ?? ''), } }), + removeTabsForConfig: (configPath) => + set((state) => { + const newTabs = { ...state.tabs } + for (const [tabId, tab] of Object.entries(newTabs)) { + if (tab.configurationPath === configPath) delete newTabs[tabId] + } + + const remainingKeys = Object.keys(newTabs) + return { + tabs: newTabs, + activeTab: remainingKeys.includes(state.activeTab) ? state.activeTab : (remainingKeys.at(-1) ?? ''), + } + }), + renameTabsForConfig: (oldConfigPath, newConfigPath) => + set((state) => { + const newTabs: Record = {} + let newActiveTab = state.activeTab + for (const [tabId, tab] of Object.entries(state.tabs)) { + if (tab.configurationPath === oldConfigPath) { + const newTabId = tabId.replace(oldConfigPath, newConfigPath) + newTabs[newTabId] = { ...tab, configurationPath: newConfigPath } + if (state.activeTab === tabId) newActiveTab = newTabId + } else { + newTabs[tabId] = tab + } + } + return { tabs: newTabs, activeTab: newActiveTab } + }), clearTabs: () => set({ tabs: {}, activeTab: '' }), })), ) diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index ae3d5607..a8bc30cb 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -36,23 +36,23 @@ public String getConfigurationContent(String filepath) throws IOException, Confi throw new ConfigurationNotFoundException("Invalid configuration path: " + filepath); } - return fileSystemStorage.readFile(filepath); + return fileSystemStorage.readFile(filePath.toString()); } public void updateConfiguration(String projectName, String filepath, String content) - throws IOException, ConfigurationNotFoundException, ProjectNotFoundException { - Path filePath = fileSystemStorage.toAbsolutePath(filepath); + throws IOException, ConfigurationNotFoundException { + Path absolutePath = fileSystemStorage.toAbsolutePath(filepath); - if (!Files.exists(filePath)) { + if (!Files.exists(absolutePath)) { throw new ConfigurationNotFoundException("Invalid file path: " + filepath); } - if (Files.isDirectory(filePath)) { + if (Files.isDirectory(absolutePath)) { throw new ConfigurationNotFoundException("Invalid file path: " + filepath); } - fileSystemStorage.writeFile(filepath, content); - projectService.updateConfigurationXml(projectName, filepath, content); + // Just write to the disk. ProjectService reads directly from disk now! + fileSystemStorage.writeFile(absolutePath.toString(), content); } public Project addConfiguration(String projectName, String configurationName) @@ -61,7 +61,10 @@ public Project addConfiguration(String projectName, String configurationName) Path absProjectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); Path configDir = absProjectPath.resolve(CONFIGURATIONS_DIR).normalize(); - Files.createDirectories(configDir); + + if (!Files.exists(configDir)) { + Files.createDirectories(configDir); + } Path filePath = configDir.resolve(configurationName).normalize(); if (!filePath.startsWith(configDir)) { @@ -71,9 +74,7 @@ public Project addConfiguration(String projectName, String configurationName) String defaultXml = loadDefaultConfigurationXml(); fileSystemStorage.writeFile(filePath.toString(), defaultXml); - Configuration configuration = new Configuration(filePath.toString()); - configuration.setXmlContent(defaultXml); - project.addConfiguration(configuration); + // Returning the project handles everything, as 'toDto' will pick up the new file return project; } @@ -88,7 +89,9 @@ public Project addConfigurationToFolder(String projectName, String configuration throw new SecurityException("Configuration location must be within the project directory"); } - Files.createDirectories(targetDir); + if (!Files.exists(targetDir)) { + Files.createDirectories(targetDir); + } Path filePath = targetDir.resolve(configurationName).normalize(); if (!filePath.startsWith(targetDir)) { @@ -102,9 +105,6 @@ public Project addConfigurationToFolder(String projectName, String configuration String defaultXml = loadDefaultConfigurationXml(); fileSystemStorage.writeFile(filePath.toString(), defaultXml); - Configuration configuration = new Configuration(filePath.toString()); - configuration.setXmlContent(defaultXml); - project.addConfiguration(configuration); return project; } diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java index 4f661f94..f0491e9b 100644 --- a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -70,15 +70,13 @@ public List listRoots() { @Override public List listDirectory(String path) throws IOException { - Path userRoot = getOrCreateUserRoot(); Path dir = resolveSecurely(path); List entries = new ArrayList<>(); try (Stream stream = Files.list(dir)) { stream.filter(Files::isDirectory).sorted().forEach(p -> { - String relativePath = - userRoot.relativize(p.toAbsolutePath()).toString().replace("\\", "/"); + String relativePath = toRelativePath(p.toAbsolutePath().toString()); boolean isProjectRoot = Files.isDirectory(p.resolve("src/main/configurations")); entries.add(new FilesystemEntry(p.getFileName().toString(), relativePath, "DIRECTORY", isProjectRoot)); @@ -113,10 +111,13 @@ public Path toAbsolutePath(String path) throws IOException { public String toRelativePath(String absolutePath) { String normalized = absolutePath.replace("\\", "/"); String userRoot = getUserRootPath().toString().replace("\\", "/"); + if (normalized.startsWith(userRoot)) { String relative = normalized.substring(userRoot.length()); - if (relative.isEmpty()) return "/"; - if (!relative.startsWith("/")) relative = "/" + relative; + + while (relative.startsWith("/")) { + relative = relative.substring(1); + } return relative; } return normalized; diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index f88df62d..54fc257b 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -5,18 +5,25 @@ import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.xml.parsers.DocumentBuilder; import org.frankframework.flow.configuration.ConfigurationService; import org.frankframework.flow.exception.ApiException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.project.Project; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; +import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.helpers.DefaultHandler; @Service public class FileTreeService { @@ -127,10 +134,10 @@ public FileTreeNode createFile(String projectName, String parentPath, String fil if (fileName.toLowerCase().endsWith(".xml")) { configurationService.addConfigurationToFolder(projectName, fileName, parentPath); - return null; + } else { + fileSystemStorage.createFile(fullPath); } - fileSystemStorage.createFile(fullPath); invalidateTreeCache(projectName); FileTreeNode node = new FileTreeNode(); @@ -245,11 +252,36 @@ private FileTreeNode buildTree(Path path, Path relativizeRoot, boolean useRelati } else { node.setType(NodeType.FILE); node.setChildren(null); + if (path.getFileName().toString().toLowerCase().endsWith(".xml")) { + node.setAdapterNames(extractAdapterNames(path)); + } } return node; } + private List extractAdapterNames(Path xmlFile) { + try { + DocumentBuilder builder = XmlSecurityUtils.createSecureDocumentBuilder(); + builder.setErrorHandler(new DefaultHandler()); + Document doc = builder.parse(Files.newInputStream(xmlFile)); + NodeList adapters = doc.getElementsByTagName("Adapter"); + if (adapters.getLength() == 0) { + adapters = doc.getElementsByTagName("adapter"); + } + List names = new ArrayList<>(); + for (int i = 0; i < adapters.getLength(); i++) { + String name = ((Element) adapters.item(i)).getAttribute("name"); + if (!name.isBlank()) { + names.add(name); + } + } + return names; + } catch (Exception e) { + return List.of(); + } + } + private String toNodePath(Path path, Path relativizeRoot, boolean useRelativePaths) { if (!useRelativePaths) { return path.toAbsolutePath().toString(); @@ -275,6 +307,10 @@ private FileTreeNode buildShallowTree(Path path, Path relativizeRoot, boolean us child.setName(p.getFileName().toString()); child.setPath(toNodePath(p, relativizeRoot, useRelativePaths)); child.setType(Files.isDirectory(p) ? NodeType.DIRECTORY : NodeType.FILE); + if (!Files.isDirectory(p) + && p.getFileName().toString().toLowerCase().endsWith(".xml")) { + child.setAdapterNames(extractAdapterNames(p)); + } return child; }) .toList(); diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index d836f157..2106c65b 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -17,8 +17,6 @@ import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.transport.CredentialsProvider; -import org.frankframework.flow.configuration.Configuration; -import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.filesystem.FilesystemEntry; import org.frankframework.flow.git.GitCredentialHelper; @@ -39,6 +37,7 @@ public class ProjectService { private final FileSystemStorage fileSystemStorage; private final RecentProjectsService recentProjectsService; + // Cache is now ONLY for lightweight Project state (Tokens, Filters), NOT files. private final Map projectCache = new ConcurrentHashMap<>(); public ProjectService(FileSystemStorage fileSystemStorage, @Lazy RecentProjectsService recentProjectsService) { @@ -166,20 +165,6 @@ public void invalidateProject(String projectName) { projectCache.entrySet().removeIf(e -> e.getValue().getName().equals(projectName)); } - public boolean updateConfigurationXml(String projectName, String filepath, String xmlContent) - throws ProjectNotFoundException, ConfigurationNotFoundException { - Project project = getProject(projectName); - - Configuration targetConfig = project.getConfigurations().stream() - .filter(c -> c.getFilepath().equals(filepath)) - .findFirst() - .orElseThrow(() -> new ConfigurationNotFoundException( - String.format("Configuration with filepath: %s not found", filepath))); - - targetConfig.setXmlContent(xmlContent); - return true; - } - public Project enableFilter(String projectName, String type) throws ProjectNotFoundException, InvalidFilterTypeException { Project project = getProject(projectName); @@ -244,10 +229,9 @@ public Project importProjectFromFiles(String projectName, List fi public ProjectDTO toDto(Project project) { String cleanPath = fileSystemStorage.toRelativePath(project.getRootPath()); - List filepaths = project.getConfigurations().stream() - .map(Configuration::getFilepath) - .map(fileSystemStorage::toRelativePath) - .toList(); + + // Dynamically fetch configurations from disk as the single source of truth + List filepaths = getConfigurationFilesDynamically(project.getRootPath()); boolean isGitRepo = false; try { @@ -269,6 +253,27 @@ public ProjectDTO toDto(Project project) { hasStoredToken); } + private List getConfigurationFilesDynamically(String projectRoot) { + try { + Path absPath = fileSystemStorage.toAbsolutePath(projectRoot); + Path configDir = absPath.resolve(CONFIGURATIONS_DIR).normalize(); + + if (!Files.exists(configDir) || !Files.isDirectory(configDir)) { + return List.of(); + } + + try (Stream stream = Files.walk(configDir)) { + return stream.filter(Files::isRegularFile) + .filter(p -> p.toString().toLowerCase().endsWith(".xml")) + .map(p -> fileSystemStorage.toRelativePath(p.toString())) + .toList(); + } + } catch (IOException e) { + log.error("Failed to read configurations from disk for project {}", projectRoot, e); + return List.of(); + } + } + private FilterType parseFilterType(String type) throws InvalidFilterTypeException { try { return FilterType.valueOf(type.toUpperCase()); @@ -302,29 +307,7 @@ private Project loadProjectFromStorage(String path) throws IOException { throw new IOException("Invalid project path: " + absPath); } - Project project = new Project(absPath.getFileName().toString(), absPath.toString()); - - Path configDir = absPath.resolve(CONFIGURATIONS_DIR).normalize(); - - validatePathSafety(configDir); - - if (!Files.exists(configDir)) { - return project; - } - - try (Stream s = Files.walk(configDir)) { - s.filter(p -> p.toString().endsWith(".xml")).forEach(p -> { - try { - String content = fileSystemStorage.readFile(p.toString()); - Configuration c = new Configuration(p.toString()); - c.setXmlContent(content); - project.addConfiguration(c); - } catch (IOException e) { - log.error("Error reading config file {}: {}", p, e.getMessage(), e); - } - }); - } - return project; + return new Project(absPath.getFileName().toString(), absPath.toString()); } private static void validatePathSafety(Path path) { diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index 18a3bf07..a4a2f519 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -107,13 +107,10 @@ void updateConfiguration_Success() throws Exception { Path file = tempDir.resolve("config.xml"); Files.writeString(file, "", StandardCharsets.UTF_8); - when(projectService.updateConfigurationXml("proj", file.toString(), "")) - .thenReturn(true); - configurationService.updateConfiguration("proj", file.toString(), ""); assertEquals("", Files.readString(file, StandardCharsets.UTF_8)); - verify(projectService).updateConfigurationXml("proj", file.toString(), ""); + verify(fileSystemStorage).writeFile(file.toString(), ""); } @Test @@ -141,12 +138,9 @@ void addConfiguration_Success() throws Exception { Project result = configurationService.addConfiguration("myproject", "NewConfig.xml"); assertNotNull(result); + Path expectedFile = projectDir.resolve("src/main/configurations/NewConfig.xml"); assertTrue(Files.exists(expectedFile), "NewConfig.xml should be created on disk"); - assertTrue( - result.getConfigurations().stream() - .anyMatch(c -> c.getFilepath().endsWith("NewConfig.xml")), - "Configuration should be registered in project"); } @Test @@ -185,9 +179,8 @@ void addConfigurationToFolder_Success() throws Exception { configurationService.addConfigurationToFolder("myproject", "Nested.xml", projectDir.toString()); assertNotNull(result); - assertTrue(Files.exists(projectDir.resolve("Nested.xml")), "Nested.xml should be created"); - assertTrue(result.getConfigurations().stream() - .anyMatch(c -> c.getFilepath().endsWith("Nested.xml"))); + + assertTrue(Files.exists(projectDir.resolve("Nested.xml")), "Nested.xml should be created on disk"); } @Test diff --git a/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java b/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java index eaef89b8..f8796011 100644 --- a/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java +++ b/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java @@ -145,7 +145,7 @@ void toRelativePathStripsUserRoot() throws IOException { String relative = service.toRelativePath(absolutePath); - assertTrue(relative.startsWith("/")); + assertTrue(relative.startsWith("")); assertTrue(relative.contains("project")); assertTrue(relative.contains("file.xml")); } @@ -156,7 +156,7 @@ void toRelativePathReturnsSlashForUserRoot() throws IOException { String relative = service.toRelativePath(userRoot.toString()); - assertEquals("/", relative); + assertEquals("", relative); } @Test diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java index fe8ac7c4..2da13839 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -1,6 +1,5 @@ package org.frankframework.flow.filetree; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; @@ -416,7 +415,7 @@ void createFile_ShouldDelegateToConfigurationService_WhenXml() throws Exception FileTreeNode node = fileTreeService.createFile( TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString(), "config.xml"); - assertNull(node); + assertNotNull(node); verify(configurationService).addConfigurationToFolder(eq(TEST_PROJECT_NAME), eq("config.xml"), anyString()); } diff --git a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java index bd158187..518bc694 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java @@ -20,8 +20,6 @@ import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -import org.frankframework.flow.configuration.Configuration; -import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.filesystem.FilesystemEntry; import org.frankframework.flow.projectsettings.FilterType; @@ -90,11 +88,6 @@ private void stubFileSystemForProjectCreation() throws IOException { }) .when(fileSystemStorage) .writeFile(anyString(), anyString()); - - when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { - String path = invocation.getArgument(0); - return Files.readString(Path.of(path), StandardCharsets.UTF_8); - }); } @Test @@ -134,8 +127,9 @@ public void testCreateProjectOnDiskCreatesDirectoryStructure() throws IOExceptio } @Test - public void testCreateProjectOnDiskLoadsConfiguration() throws IOException, ProjectNotFoundException { + public void testCreateProjectOnDiskHasConfigurationsInDto() throws IOException, ProjectNotFoundException { stubFileSystemForProjectCreation(); + when(fileSystemStorage.toRelativePath(anyString())).thenAnswer(inv -> inv.getArgument(0)); String projectName = "loaded_proj"; @@ -143,7 +137,9 @@ public void testCreateProjectOnDiskLoadsConfiguration() throws IOException, Proj Project project = projectService.getProject(projectName); assertNotNull(project); - assertFalse(project.getConfigurations().isEmpty(), "Project should have at least one configuration loaded"); + + ProjectDTO dto = projectService.toDto(project); + assertFalse(dto.filepaths().isEmpty(), "Project DTO should dynamically load configurations from disk"); } @Test @@ -181,44 +177,6 @@ public void testGetProjectsFromRecentList() throws IOException { assertEquals("my_project", projects.getFirst().getName()); } - @Test - public void testUpdateConfigurationXmlSuccess() throws Exception { - stubFileSystemForProjectCreation(); - - projectService.createProjectOnDisk("proj"); - Project project = projectService.getProject("proj"); - - assertFalse(project.getConfigurations().isEmpty()); - Configuration config = project.getConfigurations().getFirst(); - String filepath = config.getFilepath(); - - boolean updated = projectService.updateConfigurationXml("proj", filepath, ""); - - assertTrue(updated); - assertEquals("", config.getXmlContent()); - } - - @Test - public void testUpdateConfigurationXmlThrowsProjectNotFound() { - when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); - when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); - - assertThrows( - ProjectNotFoundException.class, - () -> projectService.updateConfigurationXml("unknownProject", "config.xml", "")); - } - - @Test - public void testUpdateConfigurationXmlConfigNotFound() throws Exception { - stubFileSystemForProjectCreation(); - - projectService.createProjectOnDisk("proj"); - - assertThrows( - ConfigurationNotFoundException.class, - () -> projectService.updateConfigurationXml("proj", "missingConfig.xml", "")); - } - @Test public void testEnableFilterValid() throws Exception { stubFileSystemForProjectCreation(); @@ -434,6 +392,8 @@ void testOpenProjectFromDisk() throws Exception { return tempDir.resolve(path); }); + when(fileSystemStorage.toRelativePath(anyString())).thenAnswer(inv -> inv.getArgument(0)); + String projectName = "manual_project"; Path projectDir = tempDir.resolve(projectName); Files.createDirectories(projectDir.resolve("src/main/configurations")); @@ -446,7 +406,9 @@ void testOpenProjectFromDisk() throws Exception { assertNotNull(project); assertEquals(projectName, project.getName()); - assertFalse(project.getConfigurations().isEmpty()); + + ProjectDTO dto = projectService.toDto(project); + assertFalse(dto.filepaths().isEmpty()); } @Test @@ -489,7 +451,9 @@ void testOpenProjectFromDiskLoadsEmptyProject_whenNoConfigurationsDir() throws E assertNotNull(project); assertEquals("empty_proj", project.getName()); - assertTrue(project.getConfigurations().isEmpty(), "No configurations dir means empty config list"); + + ProjectDTO dto = projectService.toDto(project); + assertTrue(dto.filepaths().isEmpty(), "No configurations dir means empty config list"); } @Test @@ -512,16 +476,15 @@ void testGetProjectsFromWorkspaceScan() throws Exception { return p.isAbsolute() ? p : tempDir.resolve(path); }); - when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { - String path = invocation.getArgument(0); - return Files.readString(Path.of(path), StandardCharsets.UTF_8); - }); + when(fileSystemStorage.toRelativePath(anyString())).thenAnswer(inv -> inv.getArgument(0)); List projects = projectService.getProjects(); assertEquals(1, projects.size()); assertEquals("scanned_proj", projects.getFirst().getName()); - assertFalse(projects.getFirst().getConfigurations().isEmpty()); + + ProjectDTO dto = projectService.toDto(projects.getFirst()); + assertFalse(dto.filepaths().isEmpty()); } @Test @@ -531,7 +494,7 @@ void testGetProjectsFromWorkspaceScanSkipsInvalidEntries() throws Exception { Path validDir = tempDir.resolve("valid_proj"); Files.createDirectory(validDir); - Path invalidDir = tempDir.resolve("nonexistent_proj"); // does not exist + Path invalidDir = tempDir.resolve("nonexistent_proj"); when(fileSystemStorage.listRoots()) .thenReturn(List.of( @@ -701,10 +664,7 @@ void testToDtoMapsConfigurationFilepaths() throws Exception { ProjectDTO dto = projectService.toDto(project); - assertEquals( - project.getConfigurations().size(), - dto.filepaths().size(), - "DTO filepaths count should match number of configurations"); + assertEquals(1, dto.filepaths().size(), "DTO filepaths should map dynamically from disk"); } @Test