From 98ac2de6d4436a8e77067acf08acd9b3f9f49b87 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Thu, 8 Jan 2026 09:46:38 +0100 Subject: [PATCH 01/16] Revamp backend for file system reading and writing --- .../projectlanding/load-project-modal.tsx | 99 +++++++++++++++++++ ...roject-modal.tsx => new-project-modal.tsx} | 4 +- .../routes/projectlanding/project-landing.tsx | 28 +++++- .../frankframework/flow/project/Project.java | 1 + .../flow/project/ProjectController.java | 24 ++++- .../projectfolder/ProjectFolderService.java | 44 +++++++++ src/main/resources/application.properties | 1 + 7 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 src/main/frontend/app/routes/projectlanding/load-project-modal.tsx rename src/main/frontend/app/routes/projectlanding/{project-modal.tsx => new-project-modal.tsx} (91%) create mode 100644 src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java diff --git a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx new file mode 100644 index 0000000..6d67682 --- /dev/null +++ b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import FolderIcon from '../../../icons/solar/Folder.svg?react' + +interface LoadProjectModalProperties { + isOpen: boolean + onClose: () => void + onCreate: (name: string) => void +} + +export default function LoadProjectModal({ isOpen, onClose, onCreate }: Readonly) { + const [folders, setFolders] = useState([]) + const [rootPath, setRootPath] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isOpen) return + + const fetchData = async () => { + setLoading(true) + setError(null) + + try { + const [foldersResponse, rootResponse] = await Promise.all([ + fetch('/api/projects/backend-folders'), + fetch('/api/projects/root'), + ]) + + if (!foldersResponse.ok) { + throw new Error(`Folders HTTP error! Status: ${foldersResponse.status}`) + } + if (!rootResponse.ok) { + throw new Error(`Root HTTP error! Status: ${rootResponse.status}`) + } + + const foldersData = await foldersResponse.json() + const rootData = await rootResponse.json() + + console.log(rootData) + + setFolders(foldersData) + setRootPath(rootData.rootPath) + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to fetch project data') + } finally { + setLoading(false) + } + } + + fetchData() + }, [isOpen]) + + if (!isOpen) return null + + const handleCreate = (name: string) => { + onCreate(name) + onClose() + } + + return ( +
+
+ {/* Header */} +
+

Load Project

+ {rootPath &&

Root: {rootPath}

} +
+ + {/* Content */} +
+ {loading &&

Loading folders...

} + {error &&

{error}

} + {!loading && !error && folders.length === 0 &&

No folders found.

} + +
    + {folders.map((folder) => ( +
  • handleCreate(folder)} + className="hover:bg-backdrop flex items-center gap-2 rounded-md px-2 py-1 hover:cursor-pointer" + > + + {folder} +
  • + ))} +
+
+ + {/* Close button */} + +
+
+ ) +} diff --git a/src/main/frontend/app/routes/projectlanding/project-modal.tsx b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx similarity index 91% rename from src/main/frontend/app/routes/projectlanding/project-modal.tsx rename to src/main/frontend/app/routes/projectlanding/new-project-modal.tsx index c61db58..8068161 100644 --- a/src/main/frontend/app/routes/projectlanding/project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' -interface ProjectModalProperties { +interface NewProjectModalProperties { isOpen: boolean onClose: () => void onCreate: (name: string) => void } -export default function ProjectModal({ isOpen, onClose, onCreate }: Readonly) { +export default function NewProjectModal({ isOpen, onClose, onCreate }: Readonly) { const [name, setName] = useState('') if (!isOpen) return null diff --git a/src/main/frontend/app/routes/projectlanding/project-landing.tsx b/src/main/frontend/app/routes/projectlanding/project-landing.tsx index 34ae115..569c707 100644 --- a/src/main/frontend/app/routes/projectlanding/project-landing.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-landing.tsx @@ -6,10 +6,12 @@ import Search from '~/components/search/search' import ActionButton from './action-button' import { useProjectStore } from '~/stores/project-store' import { useLocation } from 'react-router' -import ProjectModal from './project-modal' +import NewProjectModal from './new-project-modal' +import LoadProjectModal from './load-project-modal' export interface Project { name: string + rootPath?: string filepaths: string[] filters: Record // key = filter name (e.g. "HTTP"), value = true/false } @@ -23,7 +25,9 @@ export default function ProjectLanding() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [search, setSearch] = useState('') - const [showModal, setShowModal] = useState(false) + const [showNewProjectModal, setShowNewProjectModal] = useState(false) + const [showLoadProjectModal, setShowLoadProjectModal] = useState(false) + const clearProject = useProjectStore((state) => state.clearProject) const location = useLocation() const fileInputReference = useRef(null) @@ -97,7 +101,7 @@ export default function ProjectLanding() { setProjects((prev) => prev.map((p) => (p.name === updated.name ? updated : p))) } - const createProject = async (projectName: string) => { + const createProject = async (projectName: string, rootPath?: string) => { try { const response = await fetch(`/api/projects/${projectName}`, { method: 'POST', @@ -116,6 +120,10 @@ export default function ProjectLanding() { } } + const loadProject = async () => { + setShowLoadProjectModal(true) + } + // Filter projects by search string (case-insensitive) const filteredProjects = projects.filter((project) => project.name.toLowerCase().includes(search.toLowerCase())) @@ -151,7 +159,7 @@ export default function ProjectLanding() { {/* Content row */}
- setShowModal(true)} /> + setShowNewProjectModal(true)} /> console.log('Cloning project')} /> +
{filteredProjects.map((project, index) => ( @@ -171,7 +180,16 @@ export default function ProjectLanding() {
- setShowModal(false)} onCreate={createProject} /> + setShowNewProjectModal(false)} + onCreate={createProject} + /> + setShowLoadProjectModal(false)} + onCreate={createProject} + /> ) } diff --git a/src/main/java/org/frankframework/flow/project/Project.java b/src/main/java/org/frankframework/flow/project/Project.java index 3b5fc5c..7d24e63 100644 --- a/src/main/java/org/frankframework/flow/project/Project.java +++ b/src/main/java/org/frankframework/flow/project/Project.java @@ -21,6 +21,7 @@ @Setter public class Project { private String name; + private String rootPath; private final ArrayList configurations; private final ProjectSettings projectSettings; diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index bd1fe6b..3e6c8e1 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -1,11 +1,15 @@ package org.frankframework.flow.project; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; + import org.frankframework.flow.configuration.AdapterUpdateDTO; import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationDTO; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.projectfolder.ProjectFolderService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.utility.XmlValidator; @@ -24,9 +28,11 @@ @RequestMapping("/projects") public class ProjectController { private final ProjectService projectService; + private final ProjectFolderService projectFolderService; - public ProjectController(ProjectService projectService) { + public ProjectController(ProjectService projectService, ProjectFolderService projectFolderService) { this.projectService = projectService; + this.projectFolderService = projectFolderService; } @GetMapping @@ -41,6 +47,16 @@ public ResponseEntity> getAllProjects() { return ResponseEntity.ok(projectDTOList); } + @GetMapping("/backend-folders") + public List getBackendFolders() throws IOException { + return projectFolderService.listProjectFolders(); + } + + @GetMapping("/root") + public ResponseEntity> getProjectsRoot() { + return ResponseEntity.ok(Map.of("rootPath", projectFolderService.getProjectsRoot().toString())); + } + @GetMapping("/{projectName}") public ResponseEntity getProject(@PathVariable String projectName) throws ProjectNotFoundException { @@ -81,7 +97,8 @@ public ResponseEntity patchProject( FilterType type = entry.getKey(); Boolean enabled = entry.getValue(); - if (enabled == null) continue; + if (enabled == null) + continue; if (enabled) { project.enableFilter(type); @@ -127,7 +144,8 @@ public ResponseEntity importConfigurations( @PathVariable String projectname, @RequestBody ProjectImportDTO importDTO) { Project project = projectService.getProject(projectname); - if (project == null) return ResponseEntity.notFound().build(); + if (project == null) + return ResponseEntity.notFound().build(); for (ImportConfigurationDTO conf : importDTO.configurations()) { Configuration c = new Configuration(conf.filepath()); diff --git a/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java b/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java new file mode 100644 index 0000000..8b0bbe4 --- /dev/null +++ b/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java @@ -0,0 +1,44 @@ +package org.frankframework.flow.projectfolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ProjectFolderService { + + private final Path projectsRoot; + + public ProjectFolderService( + @Value("${app.project.root}") String rootPath) { + this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); + } + + public List listProjectFolders() throws IOException { + if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { + throw new IllegalStateException("Projects root does not exist or is not a directory"); + } + + try (Stream paths = Files.list(projectsRoot)) { + return paths + .filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } + + public Path getProjectsRoot() { + if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { + throw new IllegalStateException("Projects root does not exist or is not a directory"); + } + return projectsRoot; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 88d8a13..a628ed4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,3 +2,4 @@ spring.application.name=Flow cors.allowed.origins=* spring.web.resources.static-locations=classpath:/frontend/ +app.project.root=C:/Users/Daanv/Repositories From dc59186ee93b23b5e07f75514ee3050b0f5e0aa3 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Thu, 8 Jan 2026 14:24:42 +0100 Subject: [PATCH 02/16] Editor now fetches files from actual filesystem --- .../file-structure/editor-data-provider.ts | 147 +++++++++--------- .../file-structure/editor-file-structure.tsx | 22 +-- .../configurations/configuration-manager.tsx | 79 +++++++++- .../projectlanding/load-project-modal.tsx | 4 +- .../routes/projectlanding/project-landing.tsx | 19 ++- .../app/routes/projectlanding/project-row.tsx | 4 +- .../flow/filetree/FileTreeNode.java | 18 +++ .../flow/filetree/FileTreeService.java | 106 +++++++++++++ .../flow/filetree/NodeType.java | 6 + .../frankframework/flow/project/Project.java | 3 +- .../flow/project/ProjectController.java | 43 +++-- .../flow/project/ProjectCreateDTO.java | 5 + .../flow/project/ProjectDTO.java | 4 +- .../flow/project/ProjectService.java | 13 +- .../projectfolder/ProjectFolderService.java | 44 ------ src/main/resources/application.properties | 2 +- .../flow/project/ProjectControllerTest.java | 7 +- .../flow/project/ProjectServiceTest.java | 25 +-- .../flow/project/ProjectTest.java | 2 +- 19 files changed, 378 insertions(+), 175 deletions(-) create mode 100644 src/main/java/org/frankframework/flow/filetree/FileTreeNode.java create mode 100644 src/main/java/org/frankframework/flow/filetree/FileTreeService.java create mode 100644 src/main/java/org/frankframework/flow/filetree/NodeType.java create mode 100644 src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java delete mode 100644 src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java 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 7ed9fd9..11923c7 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 @@ -3,17 +3,85 @@ import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'reac export interface FileNode { name: string path: string - isDirectory: boolean +} + +export interface FileTreeNode { + name: string + path: string + type: 'FILE' | 'DIRECTORY' + children?: FileTreeNode[] } export default class EditorFilesDataProvider implements TreeDataProvider { private data: Record> = {} private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] - private readonly rootName: string + private readonly projectName: string + + constructor(projectName: string) { + this.projectName = projectName + this.fetchAndBuildTree() + } + + /** Fetch file tree from backend and build the provider's data */ + private async fetchAndBuildTree() { + try { + const response = await fetch(`/api/projects/${this.projectName}/tree`) + if (!response.ok) throw new Error(`HTTP error ${response.status}`) + + const tree: FileTreeNode = await response.json() + this.buildTreeFromFileTree(tree) + this.notifyListeners(['root']) + } catch (error) { + console.error('Failed to load project tree for EditorFilesDataProvider', error) + } + } + + /** Converts the backend file tree to react-complex-tree data */ + private buildTreeFromFileTree(rootNode: FileTreeNode) { + const newData: Record> = {} + + const traverse = (node: FileTreeNode, parentIndex: TreeItemIndex): TreeItemIndex => { + const index = parentIndex === 'root' ? node.name : `${parentIndex}/${node.name}` + + newData[index] = { + index, + data: { + name: node.name, + path: node.path, + }, + children: node.type === 'DIRECTORY' ? [] : undefined, + isFolder: node.type === 'DIRECTORY', + } + + if (node.type === 'DIRECTORY' && node.children) { + newData[index].children ??= [] + for (const child of node.children) { + const childIndex = traverse(child, index) + newData[index].children.push(childIndex) + } + } + + return index + } + + newData['root'] = { + index: 'root', + data: { + name: rootNode.name, + path: rootNode.path, + }, + children: [], + isFolder: rootNode.type === 'DIRECTORY', + } + + if (rootNode.children) { + for (const child of rootNode.children) { + const childIndex = traverse(child, 'root') + newData['root'].children!.push(childIndex) + } + } - constructor(rootName: string, paths: string[]) { - this.rootName = rootName - this.buildTree(rootName, paths) + this.data = newData } public async getAllItems(): Promise[]> { @@ -24,11 +92,6 @@ export default class EditorFilesDataProvider implements TreeDataProvider { return this.data[itemId] } - public updateData(paths: string[]) { - this.buildTree(this.rootName, paths) - this.notifyListeners(['root']) - } - public async onChangeItemChildren(itemId: TreeItemIndex, newChildren: TreeItemIndex[]) { this.data[itemId].children = newChildren this.notifyListeners([itemId]) @@ -47,69 +110,7 @@ export default class EditorFilesDataProvider implements TreeDataProvider { this.data[item.index].data.name = name } - private buildTree(rootName: string, paths: string[]) { - const newData: Record> = { - root: { - index: 'root', - data: { - name: rootName, - path: '', - isDirectory: true, - }, - children: [], - isFolder: true, - }, - } - - for (const fullPath of paths) { - this.addPathToTree(newData, fullPath) - } - - this.data = newData - } - - private addPathToTree( - tree: Record>, - fullPath: string, - rootIndex: TreeItemIndex = 'root', - ): TreeItemIndex { - const parts = fullPath.split('/') - - let parentIndex: TreeItemIndex = rootIndex - let currentPath = '' - - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - const isLast = i === parts.length - 1 - currentPath = currentPath ? `${currentPath}/${part}` : part - - const nodeIndex: TreeItemIndex = `${parentIndex}/${part}` - - if (!tree[nodeIndex]) { - tree[nodeIndex] = { - index: nodeIndex, - data: { - name: part, - path: currentPath, - isDirectory: !isLast, - }, - children: isLast ? undefined : [], - isFolder: !isLast, - } - } - - const parent = tree[parentIndex] - parent.children ??= [] - if (!parent.children.includes(nodeIndex)) { - parent.children.push(nodeIndex) - } - - parentIndex = nodeIndex - } - - return parentIndex - } - + /** Notify all listeners that certain nodes changed */ private notifyListeners(itemIds: TreeItemIndex[]) { for (const listener of this.treeChangeListeners) listener(itemIds) } diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index fdfa1e0..5ca0722 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -36,27 +36,31 @@ export default function EditorFileStructure() { const [highlightedItemId, setHighlightedItemId] = useState(null) const tree = useRef(null) - const dataProviderReference = useRef(new EditorFilesDataProvider('Configurations', [])) const setTabData = useEditorTabStore((state) => state.setTabData) const setActiveTab = useEditorTabStore((state) => state.setActiveTab) const getTab = useEditorTabStore((state) => state.getTab) + const [dataProvider, setDataProvider] = useState(null) + useEffect(() => { if (!project) return - dataProviderReference.current.updateData(filepaths) - }, [filepaths, project]) + + // Create a new provider with the actual project name + const provider = new EditorFilesDataProvider(project.name) + setDataProvider(provider) + }, [project]) useEffect(() => { const findMatchingItems = async () => { - if (!searchTerm || !dataProviderReference.current) { + if (!searchTerm || !dataProvider) { setMatchingItemIds([]) setActiveMatchIndex(-1) setHighlightedItemId(null) return } - const allItems = await dataProviderReference.current.getAllItems() + const allItems = await dataProvider.getAllItems() const lower = searchTerm.toLowerCase() const matches = allItems @@ -92,12 +96,12 @@ export default function EditorFileStructure() { } const handleItemClickAsync = async (itemIds: TreeItemIndex[]) => { - if (!dataProviderReference.current || itemIds.length === 0) return + if (!dataProvider || itemIds.length === 0) return const itemId = itemIds[0] if (typeof itemId !== 'string') return - const item = await dataProviderReference.current.getTreeItem(itemId) + const item = await dataProvider.getTreeItem(itemId) if (!item || item.isFolder) return const filePath = item.data.path @@ -216,7 +220,7 @@ export default function EditorFileStructure() { ) } - if (!dataProviderReference.current) return null + if (!dataProvider) return null return ( <> @@ -225,7 +229,7 @@ export default function EditorFileStructure() { state.project) + const [configFiles, setConfigFiles] = useState([]) const navigate = useNavigate() const [showModal, setShowModal] = useState(false) + useEffect(() => { + if (!currentProject) return + + const fetchTree = async () => { + try { + const response = await fetch(`/api/projects/${currentProject.name}/tree`) + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`) + } + + const tree: FileTreeNode = await response.json() + + // Find configurations directory, which is located at /src/main/configurations + const configurationDirectory = findConfigurationsDir(tree) + if (!configurationDirectory) return + + const xmlFiles = collectXmlFiles(configurationDirectory) + // Compute relative path from configsDir + const xmlFilesWithRelative = xmlFiles.map((file) => ({ + ...file, + relativePath: file.path.replace(`${configurationDirectory.path}\\`, '').replaceAll('\\', '/'), + })) + + setConfigFiles(xmlFilesWithRelative) + } catch (error) { + console.error('Failed to load project tree', error) + } + } + + fetchTree() + }, [currentProject]) + return (
@@ -27,9 +99,10 @@ export default function ConfigurationManager() { Configurations within {currentProject?.name}/src/main/configurations:

- {currentProject?.filepaths.map((filepath, index) => ( - + {configFiles.map((file) => ( + ))} + { setShowModal(true) diff --git a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx index 6d67682..2ff960e 100644 --- a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx @@ -4,7 +4,7 @@ import FolderIcon from '../../../icons/solar/Folder.svg?react' interface LoadProjectModalProperties { isOpen: boolean onClose: () => void - onCreate: (name: string) => void + onCreate: (name: string, rootPath: string) => void } export default function LoadProjectModal({ isOpen, onClose, onCreate }: Readonly) { @@ -53,7 +53,7 @@ export default function LoadProjectModal({ isOpen, onClose, onCreate }: Readonly if (!isOpen) return null const handleCreate = (name: string) => { - onCreate(name) + onCreate(name, rootPath || '') onClose() } diff --git a/src/main/frontend/app/routes/projectlanding/project-landing.tsx b/src/main/frontend/app/routes/projectlanding/project-landing.tsx index 569c707..b6a83d0 100644 --- a/src/main/frontend/app/routes/projectlanding/project-landing.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-landing.tsx @@ -11,7 +11,7 @@ import LoadProjectModal from './load-project-modal' export interface Project { name: string - rootPath?: string + rootPath: string filepaths: string[] filters: Record // key = filter name (e.g. "HTTP"), value = true/false } @@ -103,11 +103,15 @@ export default function ProjectLanding() { const createProject = async (projectName: string, rootPath?: string) => { try { - const response = await fetch(`/api/projects/${projectName}`, { + const response = await fetch(`/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ + name: projectName, + rootPath: rootPath ?? undefined, + }), }) if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`) @@ -118,6 +122,17 @@ export default function ProjectLanding() { } catch (error_) { setError(error_ instanceof Error ? error_.message : 'Failed to create project') } + + try { + const response = await fetch(`/api/projects/${projectName}/tree`) + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } + const data = await response.json() + console.log('Project tree:', data) + } catch (error_) { + console.error(error_ instanceof Error ? error_.message : 'Failed to fetch project tree') + } } const loadProject = async () => { diff --git a/src/main/frontend/app/routes/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index e677952..e9b49f1 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -28,7 +28,9 @@ export default function ProjectRow({ project }: Readonly) >
{project.name}
-

path/to/{project.name}

+

+ {project.rootPath}\{project.name} +

diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java new file mode 100644 index 0000000..ed003e6 --- /dev/null +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java @@ -0,0 +1,18 @@ +package org.frankframework.flow.filetree; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileTreeNode { + private String name; + private String path; + private NodeType type; + private List children; + + public FileTreeNode() { + } +} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java new file mode 100644 index 0000000..692cfca --- /dev/null +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -0,0 +1,106 @@ +package org.frankframework.flow.filetree; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class FileTreeService { + + private final Path projectsRoot; + + public FileTreeService( + @Value("${app.project.root}") String rootPath) { + this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); + } + + public List listProjectFolders() throws IOException { + if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { + throw new IllegalStateException("Projects root does not exist or is not a directory"); + } + + try (Stream paths = Files.list(projectsRoot)) { + return paths + .filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } + + public Path getProjectsRoot() { + if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { + throw new IllegalStateException("Projects root does not exist or is not a directory"); + } + return projectsRoot; + } + + public String readFileContent(String absoluteFilepath) throws IOException { + Path filePath = Paths.get(absoluteFilepath).toAbsolutePath().normalize(); + + // Security check: make sure file is under projects root + if (!filePath.startsWith(projectsRoot)) { + throw new IllegalArgumentException("File is outside of projects root: " + absoluteFilepath); + } + + if (!Files.exists(filePath)) { + throw new NoSuchFileException("File does not exist: " + absoluteFilepath); + } + + if (Files.isDirectory(filePath)) { + throw new IllegalArgumentException("Requested path is a directory, not a file: " + absoluteFilepath); + } + + return Files.readString(filePath, StandardCharsets.UTF_8); + } + + public FileTreeNode getProjectTree(String projectName) throws IOException { + Path projectPath = projectsRoot.resolve(projectName).normalize(); + + if (!Files.exists(projectPath) || !Files.isDirectory(projectPath)) { + throw new IllegalArgumentException("Project does not exist: " + projectName); + } + + return buildTree(projectPath); + } + + // Recursive method to build the file tree + private FileTreeNode buildTree(Path path) throws IOException { + FileTreeNode node = new FileTreeNode(); + node.setName(path.getFileName().toString()); + node.setPath(path.toAbsolutePath().toString()); + + if (Files.isDirectory(path)) { + node.setType(NodeType.DIRECTORY); + + try (Stream stream = Files.list(path)) { + List children = stream + .map(p -> { + try { + return buildTree(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); + + node.setChildren(children); + } + } else { + node.setType(NodeType.FILE); + node.setChildren(null); + } + + return node; + } + +} diff --git a/src/main/java/org/frankframework/flow/filetree/NodeType.java b/src/main/java/org/frankframework/flow/filetree/NodeType.java new file mode 100644 index 0000000..258610b --- /dev/null +++ b/src/main/java/org/frankframework/flow/filetree/NodeType.java @@ -0,0 +1,6 @@ +package org.frankframework.flow.filetree; + +public enum NodeType { + FILE, + DIRECTORY +} diff --git a/src/main/java/org/frankframework/flow/project/Project.java b/src/main/java/org/frankframework/flow/project/Project.java index 7d24e63..370d8f6 100644 --- a/src/main/java/org/frankframework/flow/project/Project.java +++ b/src/main/java/org/frankframework/flow/project/Project.java @@ -25,8 +25,9 @@ public class Project { private final ArrayList configurations; private final ProjectSettings projectSettings; - public Project(String name) { + public Project(String name, String rootPath) { this.name = name; + this.rootPath = rootPath; this.configurations = new ArrayList<>(); this.projectSettings = new ProjectSettings(); } diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 3e6c8e1..f076142 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -1,6 +1,7 @@ package org.frankframework.flow.project; import java.io.IOException; +import java.nio.file.NoSuchFileException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -9,7 +10,8 @@ import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationDTO; import org.frankframework.flow.configuration.ConfigurationNotFoundException; -import org.frankframework.flow.projectfolder.ProjectFolderService; +import org.frankframework.flow.filetree.FileTreeNode; +import org.frankframework.flow.filetree.FileTreeService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.utility.XmlValidator; @@ -28,11 +30,11 @@ @RequestMapping("/projects") public class ProjectController { private final ProjectService projectService; - private final ProjectFolderService projectFolderService; + private final FileTreeService fileTreeService; - public ProjectController(ProjectService projectService, ProjectFolderService projectFolderService) { + public ProjectController(ProjectService projectService, FileTreeService fileTreeService) { this.projectService = projectService; - this.projectFolderService = projectFolderService; + this.fileTreeService = fileTreeService; } @GetMapping @@ -49,12 +51,17 @@ public ResponseEntity> getAllProjects() { @GetMapping("/backend-folders") public List getBackendFolders() throws IOException { - return projectFolderService.listProjectFolders(); + return fileTreeService.listProjectFolders(); } @GetMapping("/root") public ResponseEntity> getProjectsRoot() { - return ResponseEntity.ok(Map.of("rootPath", projectFolderService.getProjectsRoot().toString())); + return ResponseEntity.ok(Map.of("rootPath", fileTreeService.getProjectsRoot().toString())); + } + + @GetMapping("/{name}/tree") + public FileTreeNode getProjectTree(@PathVariable String name) throws IOException { + return fileTreeService.getProjectTree(name); } @GetMapping("/{projectName}") @@ -121,22 +128,24 @@ public ResponseEntity patchProject( @PostMapping("/{projectName}/configuration") public ResponseEntity getConfigurationByPath( @PathVariable String projectName, @RequestBody ConfigurationPathDTO requestBody) - throws ProjectNotFoundException, ConfigurationNotFoundException { + throws ProjectNotFoundException, ConfigurationNotFoundException, IOException { Project project = projectService.getProject(projectName); String filepath = requestBody.filepath(); // Find configuration by filepath - for (Configuration config : project.getConfigurations()) { - if (config.getFilepath().equals(filepath)) { - ConfigurationDTO dto = new ConfigurationDTO(config.getFilepath(), config.getXmlContent()); - return ResponseEntity.ok(dto); - } + String content; + try { + content = fileTreeService.readFileContent(filepath); + } catch (NoSuchFileException e) { + throw new ConfigurationNotFoundException("Configuration file not found: " + filepath); + } catch (IllegalArgumentException e) { + throw new ConfigurationNotFoundException("Invalid configuration path: " + filepath); } - throw new ConfigurationNotFoundException( - "Configuration with filepath: " + requestBody.filepath() + " cannot be found"); + ConfigurationDTO dto = new ConfigurationDTO(filepath, content); + return ResponseEntity.ok(dto); } @PostMapping("/{projectname}/import-configurations") @@ -186,9 +195,9 @@ public ResponseEntity updateAdapter( return ResponseEntity.ok().build(); } - @PostMapping("/{projectname}") - public ResponseEntity createProject(@PathVariable String projectname) { - Project project = projectService.createProject(projectname); + @PostMapping + public ResponseEntity createProject(@RequestBody ProjectCreateDTO projectCreateDTO) { + Project project = projectService.createProject(projectCreateDTO.name(), projectCreateDTO.rootPath()); ProjectDTO dto = ProjectDTO.from(project); diff --git a/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java b/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java new file mode 100644 index 0000000..1ddf182 --- /dev/null +++ b/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.project; + +public record ProjectCreateDTO(String name, String rootPath) { + +} diff --git a/src/main/java/org/frankframework/flow/project/ProjectDTO.java b/src/main/java/org/frankframework/flow/project/ProjectDTO.java index 2f9de54..2f236d6 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectDTO.java +++ b/src/main/java/org/frankframework/flow/project/ProjectDTO.java @@ -6,7 +6,7 @@ import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.projectsettings.FilterType; -public record ProjectDTO(String name, List filepaths, Map filters) { +public record ProjectDTO(String name, String rootPath, List filepaths, Map filters) { // Factory method to create a ProjectDTO from a Project public static ProjectDTO from(Project project) { @@ -15,6 +15,6 @@ public static ProjectDTO from(Project project) { filepaths.add(configuration.getFilepath()); } return new ProjectDTO( - project.getName(), filepaths, project.getProjectSettings().getFilters()); + project.getName(), project.getRootPath(), filepaths, project.getProjectSettings().getFilters()); } } diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 5a2fdd0..3f0cfff 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -4,6 +4,8 @@ import java.io.IOException; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Optional; import javax.xml.transform.OutputKeys; @@ -18,6 +20,7 @@ import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Service; @@ -35,15 +38,17 @@ public class ProjectService { private static final String BASE_PATH = "classpath:project/"; private static final int MIN_PARTS_LENGTH = 2; private final ResourcePatternResolver resolver; + private final Path projectsRoot; @Autowired - public ProjectService(ResourcePatternResolver resolver) { + public ProjectService(ResourcePatternResolver resolver, @Value("${app.project.root}") String rootPath) { this.resolver = resolver; + this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); initiateProjects(); } - public Project createProject(String name) { - Project project = new Project(name); + public Project createProject(String name, String rootPath) { + Project project = new Project(name, rootPath); projects.add(project); return project; } @@ -200,7 +205,7 @@ private void initiateProjects() { try { project = getProject(projectName); } catch (ProjectNotFoundException e) { - project = createProject(projectName); + project = createProject(projectName, projectsRoot.toString()); } // Load XML content diff --git a/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java b/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java deleted file mode 100644 index 8b0bbe4..0000000 --- a/src/main/java/org/frankframework/flow/projectfolder/ProjectFolderService.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.frankframework.flow.projectfolder; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Service -public class ProjectFolderService { - - private final Path projectsRoot; - - public ProjectFolderService( - @Value("${app.project.root}") String rootPath) { - this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); - } - - public List listProjectFolders() throws IOException { - if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { - throw new IllegalStateException("Projects root does not exist or is not a directory"); - } - - try (Stream paths = Files.list(projectsRoot)) { - return paths - .filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .sorted() - .collect(Collectors.toList()); - } - } - - public Path getProjectsRoot() { - if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { - throw new IllegalStateException("Projects root does not exist or is not a directory"); - } - return projectsRoot; - } -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a628ed4..7d0a197 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,4 +2,4 @@ spring.application.name=Flow cors.allowed.origins=* spring.web.resources.static-locations=classpath:/frontend/ -app.project.root=C:/Users/Daanv/Repositories +app.project.root=C:/Users/daan.van.maldegem/Repositories diff --git a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java index 96cc59f..7763c39 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java @@ -328,9 +328,10 @@ void updateAdapterThrowsExceptionReturns500HandledByGlobalExceptionHandler() thr void createProjectReturnsProjectDto() throws Exception { // Arrange String projectName = "NewProject"; - Project createdProject = new Project(projectName); + String rootPath = "/path/to/new/project"; + Project createdProject = new Project(projectName, rootPath); - when(projectService.createProject(projectName)).thenReturn(createdProject); + when(projectService.createProject(projectName, rootPath)).thenReturn(createdProject); mockMvc.perform(post("/api/projects/" + projectName).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -338,7 +339,7 @@ void createProjectReturnsProjectDto() throws Exception { .andExpect(jsonPath("$.filepaths").isEmpty()) .andExpect(jsonPath("$.filters").isNotEmpty()); - verify(projectService).createProject(projectName); + verify(projectService).createProject(projectName, rootPath); } @Test diff --git a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java index 4726cd2..8819cd3 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java @@ -33,17 +33,18 @@ class ProjectServiceTest { @BeforeEach void init() throws IOException { when(resolver.getResources(anyString())).thenReturn(new Resource[0]); - projectService = new ProjectService(resolver); + projectService = new ProjectService(resolver, "/path/to/projects"); } @Test void testAddingProjectToProjectService() { String projectName = "new_project"; + String rootPath = "/path/to/new_project"; assertEquals(0, projectService.getProjects().size()); assertThrows(ProjectNotFoundException.class, () -> projectService.getProject(projectName)); - projectService.createProject(projectName); + projectService.createProject(projectName, rootPath); assertEquals(1, projectService.getProjects().size()); assertNotNull(projectService.getProject(projectName)); @@ -56,7 +57,7 @@ void testGetProjectThrowsProjectNotFound() { @Test void testUpdateConfigurationXmlSuccess() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); Project project = projectService.getProject("proj"); Configuration config = new Configuration("config.xml"); @@ -77,7 +78,7 @@ void testUpdateConfigurationXmlThrowsProjectNotFound() { @Test void testUpdateConfigurationXmlConfigNotFound() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); assertThrows( ConfigurationNotFoundException.class, @@ -86,7 +87,7 @@ void testUpdateConfigurationXmlConfigNotFound() throws Exception { @Test void testEnableFilterValid() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); Project project = projectService.enableFilter("proj", "ADAPTER"); @@ -95,7 +96,7 @@ void testEnableFilterValid() throws Exception { @Test void testDisableFilterValid() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); // enable first projectService.enableFilter("proj", "ADAPTER"); @@ -112,7 +113,7 @@ void testDisableFilterValid() throws Exception { @Test void testEnableFilterInvalidFilterType() { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); InvalidFilterTypeException ex = assertThrows( InvalidFilterTypeException.class, () -> projectService.enableFilter("proj", "INVALID_TYPE")); @@ -122,7 +123,7 @@ void testEnableFilterInvalidFilterType() { @Test void testDisableFilterInvalidFilterType() { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); InvalidFilterTypeException ex = assertThrows( InvalidFilterTypeException.class, () -> projectService.disableFilter("proj", "INVALID_TYPE")); @@ -149,7 +150,7 @@ void testDisableFilterProjectNotFound() { @Test void updateAdapterSuccess() throws Exception { // Arrange - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); Project project = projectService.getProject("proj"); String originalXml = @@ -196,7 +197,7 @@ void updateAdapterProjectNotFoundThrows() { @Test void updateAdapterConfigurationNotFoundThrows() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); ConfigurationNotFoundException ex = assertThrows(ConfigurationNotFoundException.class, () -> { projectService.updateAdapter("proj", "missing.xml", "A1", ""); @@ -207,7 +208,7 @@ void updateAdapterConfigurationNotFoundThrows() throws Exception { @Test void updateAdapterAdapterNotFoundThrows() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); Project project = projectService.getProject("proj"); String xml = @@ -231,7 +232,7 @@ void updateAdapterAdapterNotFoundThrows() throws Exception { @Test void updateAdapterInvalidXmlReturnsFalse() throws Exception { - projectService.createProject("proj"); + projectService.createProject("proj", "/path/to/proj"); Project project = projectService.getProject("proj"); String xml = diff --git a/src/test/java/org/frankframework/flow/project/ProjectTest.java b/src/test/java/org/frankframework/flow/project/ProjectTest.java index ca0ee4a..052400e 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectTest.java @@ -14,7 +14,7 @@ class ProjectTest { @BeforeEach void init() { - project = new Project("TestProject"); + project = new Project("TestProject", "/path/to/project"); } @Test From 48e6b809cdf81f96a8ffed239608786eb925f477 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Thu, 8 Jan 2026 14:50:48 +0100 Subject: [PATCH 03/16] Saving now works again for the editor, now directly writes to filesystem --- .../frontend/app/routes/editor/editor.tsx | 6 +++--- .../app/routes/studio/xml-to-json-parser.ts | 2 +- .../flow/configuration/ConfigurationDTO.java | 2 +- .../flow/filetree/FileTreeService.java | 21 ++++++++++++++++++- .../flow/project/ProjectController.java | 15 ++++++++++--- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 437b79e..ee51691 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -219,8 +219,8 @@ export default function CodeEditor() { if (!project || !activeTabFilePath) return const editor = editorReference.current - const updatedXml = editor?.getValue?.() - if (!updatedXml) return + const updatedContent = editor?.getValue?.() + if (!updatedContent) return setIsSaving(true) @@ -229,7 +229,7 @@ export default function CodeEditor() { const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filepath: activeTabFilePath, xmlContent: updatedXml }), + body: JSON.stringify({ filepath: activeTabFilePath, content: updatedContent }), }) // Parse JSON response body if it's not OK diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts index 87642a3..1a196dd 100644 --- a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts @@ -29,7 +29,7 @@ export async function getXmlString(projectName: string, filepath: string): Promi } const data = await response.json() - return data.xmlContent + return data.content } catch (error) { throw new Error(`Failed to fetch XML file for ${filepath}: ${error}`) } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationDTO.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationDTO.java index 8f27c14..e9bdece 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationDTO.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationDTO.java @@ -1,3 +1,3 @@ package org.frankframework.flow.configuration; -public record ConfigurationDTO(String filepath, String xmlContent) {} +public record ConfigurationDTO(String filepath, String content) {} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 692cfca..28d1210 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -47,7 +47,7 @@ public Path getProjectsRoot() { public String readFileContent(String absoluteFilepath) throws IOException { Path filePath = Paths.get(absoluteFilepath).toAbsolutePath().normalize(); - // Security check: make sure file is under projects root + // Make sure file is under projects root if (!filePath.startsWith(projectsRoot)) { throw new IllegalArgumentException("File is outside of projects root: " + absoluteFilepath); } @@ -63,6 +63,25 @@ public String readFileContent(String absoluteFilepath) throws IOException { return Files.readString(filePath, StandardCharsets.UTF_8); } + public void updateFileContent(String absoluteFilepath, String newContent) throws IOException { + Path filePath = Paths.get(absoluteFilepath).toAbsolutePath().normalize(); + + // Make sure file is under projects root + if (!filePath.startsWith(projectsRoot)) { + throw new IllegalArgumentException("File is outside of projects root: " + absoluteFilepath); + } + + if (!Files.exists(filePath)) { + throw new IllegalArgumentException("File does not exist: " + absoluteFilepath); + } + + if (Files.isDirectory(filePath)) { + throw new IllegalArgumentException("Cannot update a directory: " + absoluteFilepath); + } + + Files.writeString(filePath, newContent, StandardCharsets.UTF_8); + } + public FileTreeNode getProjectTree(String projectName) throws IOException { Path projectPath = projectsRoot.resolve(projectName).normalize(); diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index f076142..35183c3 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -170,11 +170,20 @@ public ResponseEntity importConfigurations( @PutMapping("/{projectName}/configuration") public ResponseEntity updateConfiguration( @PathVariable String projectName, @RequestBody ConfigurationDTO configurationDTO) - throws ProjectNotFoundException, ConfigurationNotFoundException, InvalidXmlContentException { + throws ProjectNotFoundException, ConfigurationNotFoundException, InvalidXmlContentException, IOException { - XmlValidator.validateXml(configurationDTO.xmlContent()); + // Validate XML + if (configurationDTO.filepath().toLowerCase().endsWith(".xml")) { + XmlValidator.validateXml(configurationDTO.content()); + } + + Project project = projectService.getProject(projectName); - projectService.updateConfigurationXml(projectName, configurationDTO.filepath(), configurationDTO.xmlContent()); + try { + fileTreeService.updateFileContent(configurationDTO.filepath(), configurationDTO.content()); + } catch (IllegalArgumentException e) { + throw new ConfigurationNotFoundException("Invalid file path: " + configurationDTO.filepath()); + } return ResponseEntity.ok().build(); } From 389ebd330373ab58cf63a089204ca791288beed1 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Thu, 8 Jan 2026 17:30:50 +0100 Subject: [PATCH 04/16] Can now save and read directly from local file system --- .gitignore | 1 + pom.xml | 3 + .../file-structure/file-structure.tsx | 92 ++++------ .../file-structure/files-data-provider.ts | 164 ++++++++++-------- .../projectlanding/load-project-modal.tsx | 2 - .../routes/projectlanding/project-landing.tsx | 11 -- .../app/routes/studio/canvas/flow.tsx | 8 +- .../flow/configuration/AdapterUpdateDTO.java | 2 +- .../flow/filetree/FileTreeService.java | 63 +++++++ .../flow/project/ProjectController.java | 19 +- .../flow/project/ProjectService.java | 32 +--- .../flow/utility/XmlAdapterUtils.java | 54 ++++++ src/main/resources/application.properties | 1 - 13 files changed, 273 insertions(+), 179 deletions(-) create mode 100644 src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java diff --git a/.gitignore b/.gitignore index 2dbf4a6..018f30d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ build/ .vscode/ src/main/resources/frontend/ +src/main/resources/application-local.properties diff --git a/pom.xml b/pom.xml index d616c90..9d14c29 100644 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,9 @@ + + local + org.projectlombok diff --git a/src/main/frontend/app/components/file-structure/file-structure.tsx b/src/main/frontend/app/components/file-structure/file-structure.tsx index c7eed93..3fb71bf 100644 --- a/src/main/frontend/app/components/file-structure/file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/file-structure.tsx @@ -18,10 +18,8 @@ import { } from 'react-complex-tree' import FilesDataProvider from '~/components/file-structure/files-data-provider' import { useProjectStore } from '~/stores/project-store' -import { Link } from 'react-router' -import { useTreeStore } from '~/stores/tree-store' -import { useShallow } from 'zustand/react/shallow' import { getListenerIcon } from './tree-utilities' +import type { FileTreeNode } from './editor-data-provider' export interface ConfigWithAdapters { configPath: string @@ -43,60 +41,58 @@ function getItemTitle(item: TreeItem): string { return 'Unnamed' } +function findConfigurationsDir(node: FileTreeNode): FileTreeNode | null { + const normalizedPath = node.path.replaceAll('\\', '/') + if (node.type === 'DIRECTORY' && normalizedPath.endsWith('/src/main/configurations')) { + return node + } + + if (!node.children) return null + + for (const child of node.children) { + const found = findConfigurationsDir(child) + if (found) return found + } + + return null +} + export default function FileStructure() { - const { configs, isLoading, setConfigs, setIsLoading } = useTreeStore( - useShallow((state) => ({ - configs: state.configs, - isLoading: state.isLoading, - setConfigs: state.setConfigs, - setIsLoading: state.setIsLoading, - })), - ) const project = useProjectStore.getState().project + const [isTreeLoading, setIsTreeLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [matchingItemIds, setMatchingItemIds] = useState([]) const [activeMatchIndex, setActiveMatchIndex] = useState(-1) const [highlightedItemId, setHighlightedItemId] = useState(null) const tree = useRef(null) - const dataProviderReference = useRef(new FilesDataProvider([])) - - const configurationPaths = useProjectStore((state) => state.project?.filepaths) + const dataProviderReference = useRef(new FilesDataProvider(project ? project.name : '')) const setTabData = useTabStore((state) => state.setTabData) const setActiveTab = useTabStore((state) => state.setActiveTab) const getTab = useTabStore((state) => state.getTab) useEffect(() => { - const loadAdapters = async () => { - if (configs.length > 0 || !configurationPaths) return - - // eslint-disable-next-line unicorn/consistent-function-scoping - const fetchAdapter = async (configPath: string, adapterName: string) => { - if (!project) return { adapterName, listenerName: null } - const listenerName = await getAdapterListenerType(project.name, configPath, adapterName) - return { adapterName, listenerName } - } - - const fetchConfig = async (configPath: string): Promise => { - if (!project) return { configPath, adapters: [] } + // Load in the filetree from the backend + const loadFileTree = async () => { + if (!project) return + setIsTreeLoading(true) + try { + const response = await fetch(`/api/projects/${project.name}/tree`) + const tree: FileTreeNode = await response.json() - const adapterNames = await getAdapterNamesFromConfiguration(project.name, configPath) - const adapters = await Promise.all(adapterNames.map((adapterName) => fetchAdapter(configPath, adapterName))) - return { configPath, adapters } - } + const configurationsRoot = findConfigurationsDir(tree) + if (!configurationsRoot) return - try { - const loaded = await Promise.all(configurationPaths.map((path) => fetchConfig(path))) - setConfigs(loaded) + await dataProviderReference.current.updateData(configurationsRoot) } catch (error) { - console.error('Failed to load adapter names:', error) + console.error('Failed to load file tree', error) } finally { - setIsLoading(false) + setIsTreeLoading(false) } } - loadAdapters() - }, [configurationPaths, configs.length, setConfigs, setIsLoading, project]) + loadFileTree() + }, [project]) useEffect(() => { const findMatchingItems = async () => { @@ -127,11 +123,7 @@ export default function FileStructure() { } findMatchingItems() - }, [searchTerm, configs]) - - useEffect(() => { - dataProviderReference.current.updateData(configs) - }, [configs]) + }, [searchTerm]) const handleItemClick = (items: TreeItemIndex[], _treeId: string): void => { void handleItemClickAsync(items) @@ -310,20 +302,12 @@ export default function FileStructure() { } const renderContent = () => { - if (isLoading) { - return

Loading configurations...

+ if (!project) { + return

Loading project...

} - if (configs.length === 0) { - return ( -

- No configurations found, load in a project through the  - - dashboard overview - - . -

- ) + if (isTreeLoading) { + return

Loading configurations...

} return ( diff --git a/src/main/frontend/app/components/file-structure/files-data-provider.ts b/src/main/frontend/app/components/file-structure/files-data-provider.ts index 9cc537a..9d70459 100644 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/files-data-provider.ts @@ -1,53 +1,21 @@ import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' -import type { ConfigWithAdapters } from './file-structure' - -interface AdapterNodeData { - adapterName: string - configPath: string - listenerName: string | null -} +import type { FileTreeNode } from './editor-data-provider' +import { getAdapterListenerType, getAdapterNamesFromConfiguration } from '~/routes/studio/xml-to-json-parser' export default class FilesDataProvider implements TreeDataProvider { private data: Record = {} private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] + private projectName: string - constructor(configs: ConfigWithAdapters[]) { - this.updateData(configs) - } - - public updateData(configs: ConfigWithAdapters[]) { - this.buildTree(configs) - this.notifyListeners(['root']) - } - - public async getAllItems(): Promise { - return Object.values(this.data) - } - - public async getTreeItem(itemId: TreeItemIndex) { - return this.data[itemId] - } - - public async onChangeItemChildren(itemId: TreeItemIndex, newChildren: TreeItemIndex[]) { - this.data[itemId].children = newChildren - for (const listener of this.treeChangeListeners) listener([itemId]) - } - - public onDidChangeTreeData(listener: (changedItemIds: TreeItemIndex[]) => void): Disposable { - this.treeChangeListeners.push(listener) - return { - dispose: () => { - this.treeChangeListeners.splice(this.treeChangeListeners.indexOf(listener), 1) - }, + constructor(projectName: string, fileTree?: FileTreeNode) { + this.projectName = projectName + if (fileTree) { + void this.updateData(fileTree) } } - public async onRenameItem(item: TreeItem, name: string): Promise { - this.data[item.index].data = name - } - - // eslint-disable-next-line sonarjs/cognitive-complexity - private buildTree(configs: ConfigWithAdapters[]) { + /** Update the tree using a backend fileTree */ + public async updateData(fileTree: FileTreeNode) { const newData: Record = { root: { index: 'root', @@ -57,51 +25,103 @@ export default class FilesDataProvider implements TreeDataProvider { }, } - for (const { configPath, adapters } of configs) { - // Remove the fixed src/main/configurations prefix - const relativePath = configPath.replace(/^src\/main\/configurations\//, '') + // Recursive traversal + const traverse = async (node: FileTreeNode, parentIndex: TreeItemIndex): Promise => { + // Ignore non-XML files + if (node.type === 'FILE' && !node.name.endsWith('.xml')) return null - // Split by / to create nested folders - const parts = relativePath.split('/') // e.g. ["AMQP", "Configuration.xml"] + const index = parentIndex === 'root' ? node.name : `${parentIndex}/${node.name}` - let parentIndex = 'root' - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - const isLast = i === parts.length - 1 + if (node.type === 'DIRECTORY') { + newData[index] = { + index, + data: node.name, + children: [], + isFolder: true, + } - const nodeIndex = `${parentIndex}/${part}` // unique index in tree + if (node.children) { + for (const child of node.children) { + const childIndex = await traverse(child, index) + if (childIndex && !newData[index].children!.includes(childIndex)) { + newData[index].children!.push(childIndex) + } + } + } - // If node does not exist yet, create it - if (!newData[nodeIndex]) { - newData[nodeIndex] = { - index: nodeIndex, - data: isLast ? part.replace(/\.xml$/i, '') : part, - children: isLast ? adapters.map((a) => a.adapterName) : [], - isFolder: !isLast || adapters.length > 0, - } as TreeItem + // Remove empty directories + if (newData[index].children!.length === 0) { + delete newData[index] + return null } - // Make sure parent has this child - const parentNode = newData[parentIndex] - if (!parentNode.children) parentNode.children = [] - if (!parentNode.children.includes(nodeIndex)) parentNode.children.push(nodeIndex) + return index + } - parentIndex = nodeIndex + // FILE ending with .xml + newData[index] = { + index, + data: node.name.replace(/\.xml$/, ''), + children: [], + isFolder: true, // treat .xml as folder to hold adapters + } - // Add adapters as children to the XML file node - if (isLast) { - for (const { adapterName, listenerName } of adapters) { - newData[adapterName] = { - index: adapterName, - data: { adapterName, configPath, listenerName } satisfies AdapterNodeData, - isFolder: false, - } + // Populate adapters using your shared function + try { + const adapterNames = await getAdapterNamesFromConfiguration(this.projectName, node.path) + + for (const adapterName of adapterNames) { + const adapterIndex = `${index}/${adapterName}` + newData[adapterIndex] = { + index: adapterIndex, + data: { + adapterName, + configPath: node.path, + listenerName: await getAdapterListenerType(this.projectName, node.path, adapterName), + }, + isFolder: false, // leaf node } + newData[index].children!.push(adapterIndex) } + } catch (error) { + console.error(`Failed to load adapters for ${node.path}:`, error) + } + + return index + } + + // Traverse all children of the root folder + if (fileTree.children) { + for (const child of fileTree.children) { + const childIndex = await traverse(child, 'root') + if (childIndex) newData['root'].children!.push(childIndex) } } this.data = newData + this.notifyListeners(['root']) + } + + public async getAllItems(): Promise { + return Object.values(this.data) + } + + public async getTreeItem(itemId: TreeItemIndex) { + return this.data[itemId] + } + + public async onChangeItemChildren(itemId: TreeItemIndex, newChildren: TreeItemIndex[]) { + this.data[itemId].children = newChildren + this.notifyListeners([itemId]) + } + + public onDidChangeTreeData(listener: (changedItemIds: TreeItemIndex[]) => void): Disposable { + this.treeChangeListeners.push(listener) + return { + dispose: () => { + this.treeChangeListeners.splice(this.treeChangeListeners.indexOf(listener), 1) + }, + } } private notifyListeners(itemIds: TreeItemIndex[]) { diff --git a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx index 2ff960e..56890b4 100644 --- a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx @@ -36,8 +36,6 @@ export default function LoadProjectModal({ isOpen, onClose, onCreate }: Readonly const foldersData = await foldersResponse.json() const rootData = await rootResponse.json() - console.log(rootData) - setFolders(foldersData) setRootPath(rootData.rootPath) } catch (error) { diff --git a/src/main/frontend/app/routes/projectlanding/project-landing.tsx b/src/main/frontend/app/routes/projectlanding/project-landing.tsx index b6a83d0..1fd4ab7 100644 --- a/src/main/frontend/app/routes/projectlanding/project-landing.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-landing.tsx @@ -122,17 +122,6 @@ export default function ProjectLanding() { } catch (error_) { setError(error_ instanceof Error ? error_.message : 'Failed to create project') } - - try { - const response = await fetch(`/api/projects/${projectName}/tree`) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } - const data = await response.json() - console.log('Project tree:', data) - } catch (error_) { - console.error(error_ instanceof Error ? error_.message : 'Failed to fetch project tree') - } } const loadProject = async () => { diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 0bda9c4..8bcd7f6 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -527,11 +527,15 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: try { if (!project) return - const url = `/api/projects/${encodeURIComponent(project.name)}/adapters/${encodeURIComponent(activeTabName)}` + const url = `/api/projects/${encodeURIComponent(project.name)}/adapters` const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ adapterXml: xmlString, configurationPath: configurationPath }), + body: JSON.stringify({ + adapterXml: xmlString, + adapterName: activeTabName, + configurationPath: configurationPath, + }), }) if (!response.ok) { diff --git a/src/main/java/org/frankframework/flow/configuration/AdapterUpdateDTO.java b/src/main/java/org/frankframework/flow/configuration/AdapterUpdateDTO.java index e220edd..95e1ad7 100644 --- a/src/main/java/org/frankframework/flow/configuration/AdapterUpdateDTO.java +++ b/src/main/java/org/frankframework/flow/configuration/AdapterUpdateDTO.java @@ -1,3 +1,3 @@ package org.frankframework.flow.configuration; -public record AdapterUpdateDTO(String adapterXml, String configurationPath) {} +public record AdapterUpdateDTO(String adapterXml, String adapterName, String configurationPath) {} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 28d1210..713b3a2 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -1,18 +1,27 @@ package org.frankframework.flow.filetree; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.frankframework.flow.configuration.AdapterNotFoundException; +import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.utility.XmlAdapterUtils; +import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.w3c.dom.Node; +import org.w3c.dom.Document; + @Service public class FileTreeService { @@ -92,6 +101,60 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { return buildTree(projectPath); } + public boolean updateAdapterFromFile( + String projectName, + Path configurationFile, + String adapterName, + String newAdapterXml) + throws ConfigurationNotFoundException, AdapterNotFoundException { + + if (!Files.exists(configurationFile)) { + throw new ConfigurationNotFoundException( + "Configuration file not found: " + configurationFile); + } + + try { + // Parse configuration XML from file + Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() + .parse(Files.newInputStream(configurationFile)); + + // Parse new adapter XML + Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() + .parse(new ByteArrayInputStream(newAdapterXml.getBytes(StandardCharsets.UTF_8))); + + Node newAdapterNode = newAdapterDoc.getDocumentElement(); + + // Delegate replacement logic + boolean replaced = XmlAdapterUtils.replaceAdapterInDocument( + configDoc, + adapterName, + newAdapterNode); + + if (!replaced) { + throw new AdapterNotFoundException("Adapter not found: " + adapterName); + } + + // Delegate document to string conversion + String updatedXml = XmlAdapterUtils.convertDocumentToString(configDoc); + + // Write updated XML back to file + Files.writeString( + configurationFile, + updatedXml, + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING); + + return true; + + } catch (AdapterNotFoundException | ConfigurationNotFoundException e) { + throw e; // let GlobalExceptionHandler deal with it + } catch (Exception e) { + System.err.println("Error updating adapter in file: " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + // Recursive method to build the file tree private FileTreeNode buildTree(Path path) throws IOException { FileTreeNode node = new FileTreeNode(); diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 35183c3..7a01222 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -2,6 +2,8 @@ import java.io.IOException; import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -188,14 +190,17 @@ public ResponseEntity updateConfiguration( return ResponseEntity.ok().build(); } - @PutMapping("/{projectName}/adapters/{adapterName}") - public ResponseEntity updateAdapter( + @PutMapping("/{projectName}/adapters") + public ResponseEntity updateAdapterFromFile( @PathVariable String projectName, - @PathVariable String adapterName, - @RequestBody AdapterUpdateDTO adapterUpdateDTO) { - - boolean updated = projectService.updateAdapter( - projectName, adapterUpdateDTO.configurationPath(), adapterName, adapterUpdateDTO.adapterXml()); + @RequestBody AdapterUpdateDTO dto) { + Path configPath = Paths.get(dto.configurationPath()); + + boolean updated = fileTreeService.updateAdapterFromFile( + projectName, + configPath, + dto.adapterName(), + dto.adapterXml()); if (!updated) { return ResponseEntity.notFound().build(); diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 3f0cfff..80e29a9 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -18,6 +18,7 @@ import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; +import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -151,11 +152,11 @@ public boolean updateAdapter(String projectName, String configurationPath, Strin Node newAdapterNode = configDoc.importNode(newAdapterDoc.getDocumentElement(), true); - if (!replaceAdapterInDocument(configDoc, adapterName, newAdapterNode)) { + if (!XmlAdapterUtils.replaceAdapterInDocument(configDoc, adapterName, newAdapterNode)) { throw new AdapterNotFoundException("Adapter not found: " + adapterName); } - String xmlOutput = convertDocumentToString(configDoc); + String xmlOutput = XmlAdapterUtils.convertDocumentToString(configDoc); config.setXmlContent(xmlOutput); return true; @@ -223,31 +224,4 @@ private void initiateProjects() { e.printStackTrace(); } } - - private boolean replaceAdapterInDocument(Document configDoc, String adapterName, Node newAdapterNode) { - NodeList adapters = configDoc.getElementsByTagName("Adapter"); - for (int i = 0; i < adapters.getLength(); i++) { - Element adapter = (Element) adapters.item(i); - if (adapterName.equals(adapter.getAttribute("name"))) { - adapter.getParentNode().replaceChild(newAdapterNode, adapter); - return true; - } - } - return false; - } - - private String convertDocumentToString(Document doc) throws Exception { - Transformer transformer = - XmlSecurityUtils.createSecureTransformerFactory().newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); - transformer.setOutputProperty(OutputKeys.METHOD, "xml"); - transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); - transformer.setOutputProperty("{http://xml.apache.org/xalan}line-separator", "\n"); - - StringWriter writer = new StringWriter(); - transformer.transform(new DOMSource(doc), new StreamResult(writer)); - - return writer.toString().replaceAll("(?m)^[ \t]*\r?\n", ""); - } } diff --git a/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java new file mode 100644 index 0000000..dfc4658 --- /dev/null +++ b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java @@ -0,0 +1,54 @@ +package org.frankframework.flow.utility; + +import lombok.experimental.UtilityClass; +import org.w3c.dom.*; + +import javax.xml.transform.*; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.StringWriter; + +@UtilityClass +public class XmlAdapterUtils { + + /** + * Replaces an Adapter element (matched by name attribute) inside the given configuration document. + */ + public static boolean replaceAdapterInDocument( + Document configDoc, + String adapterName, + Node newAdapterNode + ) { + NodeList adapters = configDoc.getElementsByTagName("Adapter"); + + for (int i = 0; i < adapters.getLength(); i++) { + Element adapter = (Element) adapters.item(i); + if (adapterName.equals(adapter.getAttribute("name"))) { + Node importedNode = configDoc.importNode(newAdapterNode, true); + adapter.getParentNode().replaceChild(importedNode, adapter); + return true; + } + } + return false; + } + + /** + * Converts a DOM Document to a formatted XML string. + */ + public static String convertDocumentToString(Document doc) throws Exception { + Transformer transformer = + XmlSecurityUtils.createSecureTransformerFactory().newTransformer(); + + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.setOutputProperty(OutputKeys.METHOD, "xml"); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xalan}line-separator", "\n"); + + StringWriter writer = new StringWriter(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + + // Remove empty lines + return writer.toString().replaceAll("(?m)^[ \t]*\r?\n", ""); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d0a197..88d8a13 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,4 +2,3 @@ spring.application.name=Flow cors.allowed.origins=* spring.web.resources.static-locations=classpath:/frontend/ -app.project.root=C:/Users/daan.van.maldegem/Repositories From 539100a9be45eb78ca51443cc40d48f148282524 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Fri, 9 Jan 2026 11:43:46 +0100 Subject: [PATCH 05/16] Updated controllertests --- .../flow/filetree/FileTreeNode.java | 4 +- .../flow/filetree/FileTreeService.java | 36 ++-- .../flow/project/ProjectController.java | 24 +-- .../flow/project/ProjectCreateDTO.java | 4 +- .../flow/project/ProjectDTO.java | 5 +- .../flow/project/ProjectService.java | 7 - .../flow/utility/XmlAdapterUtils.java | 13 +- .../flow/FlowApplicationTests.java | 2 +- .../flow/project/ProjectControllerTest.java | 170 +++++++++--------- 9 files changed, 111 insertions(+), 154 deletions(-) diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java index ed003e6..9906ff4 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java @@ -1,7 +1,6 @@ package org.frankframework.flow.filetree; import java.util.List; - import lombok.Getter; import lombok.Setter; @@ -13,6 +12,5 @@ public class FileTreeNode { private NodeType type; private List children; - public FileTreeNode() { - } + public FileTreeNode() {} } diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 713b3a2..d55b00c 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -11,24 +11,21 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; - import org.frankframework.flow.configuration.AdapterNotFoundException; import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; - -import org.w3c.dom.Node; import org.w3c.dom.Document; +import org.w3c.dom.Node; @Service public class FileTreeService { private final Path projectsRoot; - public FileTreeService( - @Value("${app.project.root}") String rootPath) { + public FileTreeService(@Value("${app.project.root}") String rootPath) { this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); } @@ -38,8 +35,7 @@ public List listProjectFolders() throws IOException { } try (Stream paths = Files.list(projectsRoot)) { - return paths - .filter(Files::isDirectory) + return paths.filter(Files::isDirectory) .map(path -> path.getFileName().toString()) .sorted() .collect(Collectors.toList()); @@ -102,21 +98,17 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { } public boolean updateAdapterFromFile( - String projectName, - Path configurationFile, - String adapterName, - String newAdapterXml) + String projectName, Path configurationFile, String adapterName, String newAdapterXml) throws ConfigurationNotFoundException, AdapterNotFoundException { if (!Files.exists(configurationFile)) { - throw new ConfigurationNotFoundException( - "Configuration file not found: " + configurationFile); + throw new ConfigurationNotFoundException("Configuration file not found: " + configurationFile); } try { // Parse configuration XML from file - Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() - .parse(Files.newInputStream(configurationFile)); + Document configDoc = + XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(configurationFile)); // Parse new adapter XML Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() @@ -125,10 +117,7 @@ public boolean updateAdapterFromFile( Node newAdapterNode = newAdapterDoc.getDocumentElement(); // Delegate replacement logic - boolean replaced = XmlAdapterUtils.replaceAdapterInDocument( - configDoc, - adapterName, - newAdapterNode); + boolean replaced = XmlAdapterUtils.replaceAdapterInDocument(configDoc, adapterName, newAdapterNode); if (!replaced) { throw new AdapterNotFoundException("Adapter not found: " + adapterName); @@ -139,10 +128,7 @@ public boolean updateAdapterFromFile( // Write updated XML back to file Files.writeString( - configurationFile, - updatedXml, - StandardCharsets.UTF_8, - StandardOpenOption.TRUNCATE_EXISTING); + configurationFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); return true; @@ -165,8 +151,7 @@ private FileTreeNode buildTree(Path path) throws IOException { node.setType(NodeType.DIRECTORY); try (Stream stream = Files.list(path)) { - List children = stream - .map(p -> { + List children = stream.map(p -> { try { return buildTree(p); } catch (IOException e) { @@ -184,5 +169,4 @@ private FileTreeNode buildTree(Path path) throws IOException { return node; } - } diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 7a01222..c3d2ed9 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -7,7 +7,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; - import org.frankframework.flow.configuration.AdapterUpdateDTO; import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationDTO; @@ -58,7 +57,8 @@ public List getBackendFolders() throws IOException { @GetMapping("/root") public ResponseEntity> getProjectsRoot() { - return ResponseEntity.ok(Map.of("rootPath", fileTreeService.getProjectsRoot().toString())); + return ResponseEntity.ok( + Map.of("rootPath", fileTreeService.getProjectsRoot().toString())); } @GetMapping("/{name}/tree") @@ -106,8 +106,7 @@ public ResponseEntity patchProject( FilterType type = entry.getKey(); Boolean enabled = entry.getValue(); - if (enabled == null) - continue; + if (enabled == null) continue; if (enabled) { project.enableFilter(type); @@ -132,8 +131,6 @@ public ResponseEntity getConfigurationByPath( @PathVariable String projectName, @RequestBody ConfigurationPathDTO requestBody) throws ProjectNotFoundException, ConfigurationNotFoundException, IOException { - Project project = projectService.getProject(projectName); - String filepath = requestBody.filepath(); // Find configuration by filepath @@ -155,8 +152,7 @@ public ResponseEntity importConfigurations( @PathVariable String projectname, @RequestBody ProjectImportDTO importDTO) { Project project = projectService.getProject(projectname); - if (project == null) - return ResponseEntity.notFound().build(); + if (project == null) return ResponseEntity.notFound().build(); for (ImportConfigurationDTO conf : importDTO.configurations()) { Configuration c = new Configuration(conf.filepath()); @@ -179,8 +175,6 @@ public ResponseEntity updateConfiguration( XmlValidator.validateXml(configurationDTO.content()); } - Project project = projectService.getProject(projectName); - try { fileTreeService.updateFileContent(configurationDTO.filepath(), configurationDTO.content()); } catch (IllegalArgumentException e) { @@ -192,15 +186,11 @@ public ResponseEntity updateConfiguration( @PutMapping("/{projectName}/adapters") public ResponseEntity updateAdapterFromFile( - @PathVariable String projectName, - @RequestBody AdapterUpdateDTO dto) { + @PathVariable String projectName, @RequestBody AdapterUpdateDTO dto) { Path configPath = Paths.get(dto.configurationPath()); - boolean updated = fileTreeService.updateAdapterFromFile( - projectName, - configPath, - dto.adapterName(), - dto.adapterXml()); + boolean updated = + fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), dto.adapterXml()); if (!updated) { return ResponseEntity.notFound().build(); diff --git a/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java b/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java index 1ddf182..3e3ae29 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java +++ b/src/main/java/org/frankframework/flow/project/ProjectCreateDTO.java @@ -1,5 +1,3 @@ package org.frankframework.flow.project; -public record ProjectCreateDTO(String name, String rootPath) { - -} +public record ProjectCreateDTO(String name, String rootPath) {} diff --git a/src/main/java/org/frankframework/flow/project/ProjectDTO.java b/src/main/java/org/frankframework/flow/project/ProjectDTO.java index 2f236d6..992c911 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectDTO.java +++ b/src/main/java/org/frankframework/flow/project/ProjectDTO.java @@ -15,6 +15,9 @@ public static ProjectDTO from(Project project) { filepaths.add(configuration.getFilepath()); } return new ProjectDTO( - project.getName(), project.getRootPath(), filepaths, project.getProjectSettings().getFilters()); + project.getName(), + project.getRootPath(), + filepaths, + project.getProjectSettings().getFilters()); } } diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 80e29a9..acd72ae 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -2,16 +2,11 @@ import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Optional; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; import lombok.Getter; import org.frankframework.flow.configuration.AdapterNotFoundException; import org.frankframework.flow.configuration.Configuration; @@ -26,9 +21,7 @@ import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Service; import org.w3c.dom.Document; -import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; @Service public class ProjectService { diff --git a/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java index dfc4658..dc5bed6 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java +++ b/src/main/java/org/frankframework/flow/utility/XmlAdapterUtils.java @@ -1,12 +1,11 @@ package org.frankframework.flow.utility; -import lombok.experimental.UtilityClass; -import org.w3c.dom.*; - +import java.io.StringWriter; import javax.xml.transform.*; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; -import java.io.StringWriter; +import lombok.experimental.UtilityClass; +import org.w3c.dom.*; @UtilityClass public class XmlAdapterUtils { @@ -14,11 +13,7 @@ public class XmlAdapterUtils { /** * Replaces an Adapter element (matched by name attribute) inside the given configuration document. */ - public static boolean replaceAdapterInDocument( - Document configDoc, - String adapterName, - Node newAdapterNode - ) { + public static boolean replaceAdapterInDocument(Document configDoc, String adapterName, Node newAdapterNode) { NodeList adapters = configDoc.getElementsByTagName("Adapter"); for (int i = 0; i < adapters.getLength(); i++) { diff --git a/src/test/java/org/frankframework/flow/FlowApplicationTests.java b/src/test/java/org/frankframework/flow/FlowApplicationTests.java index d6de88a..1287039 100644 --- a/src/test/java/org/frankframework/flow/FlowApplicationTests.java +++ b/src/test/java/org/frankframework/flow/FlowApplicationTests.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(properties = {"app.project.root=target/test-projects"}) class FlowApplicationTests { @Test diff --git a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java index 7763c39..7490221 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java @@ -6,11 +6,13 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.nio.file.NoSuchFileException; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.frankframework.flow.configuration.Configuration; -import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filetree.FileTreeService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.projectsettings.ProjectSettings; @@ -21,8 +23,7 @@ import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -32,16 +33,11 @@ class ProjectControllerTest { @Autowired private MockMvc mockMvc; - @Autowired - ProjectService projectService; + @MockBean + private ProjectService projectService; - @TestConfiguration - static class MockConfig { - @Bean - ProjectService projectService() { - return mock(ProjectService.class); - } - } + @MockBean + private FileTreeService fileTreeService; private Project mockProject() { Project project = mock(Project.class); @@ -107,13 +103,10 @@ void getProjectThrowsNotFoundReturns404() throws Exception { @Test void getConfigurationByPathReturnsExpectedJson() throws Exception { - Project project = mockProject(); - Configuration config = project.getConfigurations().get(0); - - when(config.getFilepath()).thenReturn("config1.xml"); - when(config.getXmlContent()).thenReturn("content"); + String filepath = "config1.xml"; + String xmlContent = "content"; - when(projectService.getProject("MyProject")).thenReturn(project); + when(fileTreeService.readFileContent(filepath)).thenReturn(xmlContent); String requestBody = """ @@ -127,16 +120,18 @@ void getConfigurationByPathReturnsExpectedJson() throws Exception { .accept(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.filepath").value("config1.xml")) - .andExpect(jsonPath("$.xmlContent").value("content")); + .andExpect(jsonPath("$.filepath").value(filepath)) + .andExpect(jsonPath("$.content").value(xmlContent)); - verify(projectService).getProject("MyProject"); + // Verify + verify(fileTreeService).readFileContent(filepath); } @Test void getConfigurationConfigurationNotFoundReturns404() throws Exception { - Project project = mockProject(); // project has only "config1.xml" - when(projectService.getProject("MyProject")).thenReturn(project); + String filepath = "unknown.xml"; + + when(fileTreeService.readFileContent(filepath)).thenThrow(new NoSuchFileException(filepath)); String requestBody = """ @@ -151,17 +146,17 @@ void getConfigurationConfigurationNotFoundReturns404() throws Exception { .content(requestBody)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("ConfigurationNotFound")) - .andExpect(jsonPath("$.message").value("Configuration with filepath: unknown.xml cannot be found")); + .andExpect(jsonPath("$.message").value("Configuration file not found: " + filepath)); - verify(projectService).getProject("MyProject"); + verify(fileTreeService).readFileContent(filepath); } @Test void updateConfigurationSuccessReturns200() throws Exception { + String filepath = "config1.xml"; String xmlContent = "updated"; - when(projectService.updateConfigurationXml("MyProject", "config1.xml", xmlContent)) - .thenReturn(true); + doNothing().when(fileTreeService).updateFileContent(filepath, xmlContent); mockMvc.perform( put("/api/projects/MyProject/configuration") @@ -170,42 +165,22 @@ void updateConfigurationSuccessReturns200() throws Exception { """ { "filepath": "config1.xml", - "xmlContent": "updated" + "content": "updated" } """)) .andExpect(status().isOk()); - verify(projectService).updateConfigurationXml("MyProject", "config1.xml", xmlContent); - } - - @Test - void updateConfigurationProjectNotFoundReturns404() throws Exception { - doThrow(new ProjectNotFoundException("Project not found")) - .when(projectService) - .updateConfigurationXml("UnknownProject", "config1.xml", "updated"); - - mockMvc.perform( - put("/api/projects/UnknownProject/configuration") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "filepath": "config1.xml", - "xmlContent": "updated" - } - """)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.error").value("ProjectNotFound")) - .andExpect(jsonPath("$.message").value("Project not found")); - - verify(projectService).updateConfigurationXml("UnknownProject", "config1.xml", "updated"); + verify(fileTreeService).updateFileContent(filepath, xmlContent); } @Test void updateConfigurationConfigurationNotFoundReturns404() throws Exception { - doThrow(new ConfigurationNotFoundException("Configuration not found")) - .when(projectService) - .updateConfigurationXml("MyProject", "unknown.xml", "updated"); + String filepath = "unknown.xml"; + String xmlContent = "updated"; + + doThrow(new IllegalArgumentException("Invalid path")) + .when(fileTreeService) + .updateFileContent(filepath, xmlContent); mockMvc.perform( put("/api/projects/MyProject/configuration") @@ -214,14 +189,14 @@ void updateConfigurationConfigurationNotFoundReturns404() throws Exception { """ { "filepath": "unknown.xml", - "xmlContent": "updated" + "content": "updated" } """)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("ConfigurationNotFound")) - .andExpect(jsonPath("$.message").value("Configuration not found")); + .andExpect(jsonPath("$.message").value("Invalid file path: " + filepath)); - verify(projectService).updateConfigurationXml("MyProject", "unknown.xml", "updated"); + verify(fileTreeService).updateFileContent(filepath, xmlContent); } @Test @@ -230,7 +205,6 @@ void updateConfigurationValidationErrorReturns400() throws Exception { try (MockedStatic validatorMock = Mockito.mockStatic(XmlValidator.class)) { - // Mock validator to throw the exception instead of returning a string validatorMock .when(() -> XmlValidator.validateXml(invalidXml)) .thenThrow(new InvalidXmlContentException("Malformed XML")); @@ -240,80 +214,92 @@ void updateConfigurationValidationErrorReturns400() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content( """ - { - "filepath": "config1.xml", - "xmlContent": "" - } - """)) + { + "filepath": "config1.xml", + "content": "" + } + """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("InvalidXmlContent")) .andExpect(jsonPath("$.message").value("Malformed XML")); - // Service should never be called if validation failed - verify(projectService, never()).updateConfigurationXml(anyString(), anyString(), anyString()); + verify(fileTreeService, never()).updateFileContent(anyString(), anyString()); } } @Test - void updateAdapterSuccessReturns200() throws Exception { - when(projectService.updateAdapter( - eq("MyProject"), eq("config1.xml"), eq("MyAdapter"), eq("updated"))) + void updateAdapterFromFileSuccessReturns200() throws Exception { + String projectName = "MyProject"; + String configPath = "config1.xml"; + String adapterName = "MyAdapter"; + String adapterXml = "updated"; + + when(fileTreeService.updateAdapterFromFile( + eq(projectName), eq(Paths.get(configPath)), eq(adapterName), eq(adapterXml))) .thenReturn(true); mockMvc.perform( - put("/api/projects/MyProject/adapters/MyAdapter") + put("/api/projects/MyProject/adapters") .contentType(MediaType.APPLICATION_JSON) .content( """ { "configurationPath": "config1.xml", + "adapterName": "MyAdapter", "adapterXml": "updated" } """)) .andExpect(status().isOk()); - verify(projectService).updateAdapter("MyProject", "config1.xml", "MyAdapter", "updated"); + verify(fileTreeService).updateAdapterFromFile(projectName, Paths.get(configPath), adapterName, adapterXml); } @Test - void updateAdapterNotFoundReturns404() throws Exception { - - when(projectService.updateAdapter( - eq("MyProject"), eq("config1.xml"), eq("UnknownAdapter"), eq("something"))) + void updateAdapterFromFileNotFoundReturns404() throws Exception { + String projectName = "MyProject"; + String configPath = "config1.xml"; + String adapterName = "UnknownAdapter"; + String adapterXml = "something"; + + when(fileTreeService.updateAdapterFromFile( + eq(projectName), eq(Paths.get(configPath)), eq(adapterName), eq(adapterXml))) .thenReturn(false); mockMvc.perform( - put("/api/projects/MyProject/adapters/UnknownAdapter") + put("/api/projects/MyProject/adapters") .contentType(MediaType.APPLICATION_JSON) .content( """ { "configurationPath": "config1.xml", + "adapterName": "UnknownAdapter", "adapterXml": "something" } """)) .andExpect(status().isNotFound()); - verify(projectService) - .updateAdapter("MyProject", "config1.xml", "UnknownAdapter", "something"); + verify(fileTreeService).updateAdapterFromFile(projectName, Paths.get(configPath), adapterName, adapterXml); } @Test - void updateAdapterThrowsExceptionReturns500HandledByGlobalExceptionHandler() throws Exception { - String xml = "broken"; + void updateAdapterFromFileThrowsExceptionReturns500HandledByGlobalExceptionHandler() throws Exception { + String projectName = "MyProject"; + String configPath = "config1.xml"; + String adapterName = "MyAdapter"; + String adapterXml = "broken"; - // Force generic runtime exception → GlobalExceptionHandler should catch it doThrow(new RuntimeException("Something went wrong")) - .when(projectService) - .updateAdapter(eq("MyProject"), eq("config1.xml"), eq("MyAdapter"), eq("broken")); + .when(fileTreeService) + .updateAdapterFromFile(eq(projectName), eq(Paths.get(configPath)), eq(adapterName), eq(adapterXml)); mockMvc.perform( - put("/api/projects/MyProject/adapters/MyAdapter") + put("/api/projects/MyProject/adapters") .contentType(MediaType.APPLICATION_JSON) .content( """ { "configurationPath": "config1.xml", + "adapterName": "MyAdapter", "adapterXml": "broken" } """)) @@ -321,19 +307,29 @@ void updateAdapterThrowsExceptionReturns500HandledByGlobalExceptionHandler() thr .andExpect(jsonPath("$.error").value("InternalServerError")) .andExpect(jsonPath("$.message").value("An unexpected error occurred.")); - verify(projectService).updateAdapter("MyProject", "config1.xml", "MyAdapter", xml); + verify(fileTreeService).updateAdapterFromFile(projectName, Paths.get(configPath), adapterName, adapterXml); } @Test void createProjectReturnsProjectDto() throws Exception { - // Arrange String projectName = "NewProject"; String rootPath = "/path/to/new/project"; + Project createdProject = new Project(projectName, rootPath); when(projectService.createProject(projectName, rootPath)).thenReturn(createdProject); - mockMvc.perform(post("/api/projects/" + projectName).accept(MediaType.APPLICATION_JSON)) + mockMvc.perform( + post("/api/projects") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content( + """ + { + "name": "NewProject", + "rootPath": "/path/to/new/project" + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value(projectName)) .andExpect(jsonPath("$.filepaths").isEmpty()) From 9171b39b4ffd557bad60211f149f74055f682a94 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Fri, 9 Jan 2026 12:26:45 +0100 Subject: [PATCH 06/16] Test updates --- .../flow/project/ProjectService.java | 18 ++++++++++++------ .../flow/cypress/RunCypressE2eTest.java | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index acd72ae..f219d6e 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Node; +import org.xml.sax.SAXParseException; @Service public class ProjectService { @@ -137,11 +138,13 @@ public boolean updateAdapter(String projectName, String configurationPath, Strin Configuration config = configOptional.get(); try { + // Parse existing config Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() - .parse(new ByteArrayInputStream(config.getXmlContent().getBytes())); + .parse(new ByteArrayInputStream(config.getXmlContent().getBytes(StandardCharsets.UTF_8))); + // Parse new adapter Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() - .parse(new ByteArrayInputStream(newAdapterXml.getBytes())); + .parse(new ByteArrayInputStream(newAdapterXml.getBytes(StandardCharsets.UTF_8))); Node newAdapterNode = configDoc.importNode(newAdapterDoc.getDocumentElement(), true); @@ -155,14 +158,16 @@ public boolean updateAdapter(String projectName, String configurationPath, Strin return true; } catch (AdapterNotFoundException | ConfigurationNotFoundException | ProjectNotFoundException e) { - // rethrow explicitly so they bubble up to GlobalExceptionHandler throw e; + } catch (SAXParseException e) { + System.err.println("Invalid XML for adapter " + adapterName + ": " + e.getMessage()); + return false; } catch (Exception e) { - // Other unexpected exceptions still return false - System.err.println("Error updating adapter: " + e.getMessage()); + System.err.println("Unexpected error updating adapter: " + e.getMessage()); e.printStackTrace(); return false; } + } public Project addConfiguration(String projectName, String configurationName) { @@ -189,7 +194,8 @@ private void initiateProjects() { // Example path: file:/.../resources/project/testproject/Configuration1.xml // Extract the project name between "project/" and the next "/" String[] parts = path.split("/project/"); - if (parts.length < MIN_PARTS_LENGTH) continue; + if (parts.length < MIN_PARTS_LENGTH) + continue; String relativePath = parts[1]; // e.g. "testproject/Configuration1.xml" String projectName = relativePath.substring(0, relativePath.indexOf("/")); diff --git a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java index cf72400..103ada9 100644 --- a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java +++ b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.TestFactory; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.TestPropertySource; import org.testcontainers.junit.jupiter.Testcontainers; /** @@ -39,6 +40,7 @@ * @author Sergi Philipsen * @see "https://github.com/wimdeblauwe/testcontainers-cypress" */ +@TestPropertySource(properties = "app.project.root=/tmp/flow-projects") @Testcontainers(disabledWithoutDocker = true) @Tag("integration") public class RunCypressE2eTest { From 99c171d3ae114e9430ca4b7d7913908ffc881f0a Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Fri, 9 Jan 2026 12:33:33 +0100 Subject: [PATCH 07/16] Pipeline fix? --- .../java/org/frankframework/flow/project/ProjectService.java | 4 +--- .../org/frankframework/flow/cypress/RunCypressE2eTest.java | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index f219d6e..1819d0e 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -167,7 +167,6 @@ public boolean updateAdapter(String projectName, String configurationPath, Strin e.printStackTrace(); return false; } - } public Project addConfiguration(String projectName, String configurationName) { @@ -194,8 +193,7 @@ private void initiateProjects() { // Example path: file:/.../resources/project/testproject/Configuration1.xml // Extract the project name between "project/" and the next "/" String[] parts = path.split("/project/"); - if (parts.length < MIN_PARTS_LENGTH) - continue; + if (parts.length < MIN_PARTS_LENGTH) continue; String relativePath = parts[1]; // e.g. "testproject/Configuration1.xml" String projectName = relativePath.substring(0, relativePath.indexOf("/")); diff --git a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java index 103ada9..041fac5 100644 --- a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java +++ b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.stream.Stream; @@ -28,7 +29,6 @@ import org.junit.jupiter.api.TestFactory; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.test.context.TestPropertySource; import org.testcontainers.junit.jupiter.Testcontainers; /** @@ -40,7 +40,6 @@ * @author Sergi Philipsen * @see "https://github.com/wimdeblauwe/testcontainers-cypress" */ -@TestPropertySource(properties = "app.project.root=/tmp/flow-projects") @Testcontainers(disabledWithoutDocker = true) @Tag("integration") public class RunCypressE2eTest { @@ -61,6 +60,7 @@ static void setUp() { private static void startApplication() { SpringApplication springApplication = FlowApplication.configureApplication(); + springApplication.setDefaultProperties(Map.of("app.project.root", "/tmp/flow-projects")); run = springApplication.run(); assertTrue(run.isRunning()); From f18b6c01831c6847fa514362559ac2a491d5dabd Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Fri, 9 Jan 2026 12:48:12 +0100 Subject: [PATCH 08/16] Added testing for filetreeservice --- .../flow/filetree/FileTreeService.java | 1 - .../flow/filetree/FileTreeServiceTest.java | 252 ++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index d55b00c..2c7577f 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -136,7 +136,6 @@ public boolean updateAdapterFromFile( throw e; // let GlobalExceptionHandler deal with it } catch (Exception e) { System.err.println("Error updating adapter in file: " + e.getMessage()); - e.printStackTrace(); return false; } } diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java new file mode 100644 index 0000000..730eb54 --- /dev/null +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -0,0 +1,252 @@ +package org.frankframework.flow.filetree; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.*; +import java.util.List; +import org.frankframework.flow.configuration.AdapterNotFoundException; +import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileTreeServiceTest { + + private Path tempRoot; + private FileTreeService fileTreeService; + + @BeforeEach + void setUp() throws IOException { + // Create a temporary directory for testing + tempRoot = Files.createTempDirectory("flow-projects-test"); + fileTreeService = new FileTreeService(tempRoot.toString()); + + // Create some sample project folders and files + Files.createDirectory(tempRoot.resolve("ProjectA")); + Files.createDirectory(tempRoot.resolve("ProjectB")); + Files.writeString(tempRoot.resolve("ProjectA/config1.xml"), "original"); + } + + @Test + void listProjectFoldersReturnsAllFolders() throws IOException { + List folders = fileTreeService.listProjectFolders(); + + assertEquals(2, folders.size()); + assertTrue(folders.contains("ProjectA")); + assertTrue(folders.contains("ProjectB")); + } + + @Test + void listProjectFoldersThrowsIfRootDoesNotExist() { + // Provide a path that does not exist + String nonExistentPath = "some/nonexistent/path"; + FileTreeService fileTreeService = new FileTreeService(nonExistentPath); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, fileTreeService::listProjectFolders); + + assert exception.getMessage().contains("Projects root does not exist or is not a directory"); + } + + @Test + void listProjectFoldersThrowsIfRootIsAFile() throws IOException { + // Create a temporary file (not a directory) + Path tempFile = Files.createTempFile("not-a-directory", ".txt"); + FileTreeService fileTreeService = new FileTreeService(tempFile.toString()); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, fileTreeService::listProjectFolders); + + assert exception.getMessage().contains("Projects root does not exist or is not a directory"); + + Files.deleteIfExists(tempFile); + } + + @Test + void readFileContentReturnsContentWhenFileExists() throws IOException { + // Setup a temporary project root and file + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path file = Files.createTempFile(projectRoot, "config", ".xml"); + String expectedContent = "hello"; + Files.writeString(file, expectedContent); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + String content = fileTreeService.readFileContent(file.toString()); + assertEquals(expectedContent, content); + + Files.deleteIfExists(file); + Files.deleteIfExists(projectRoot); + } + + @Test + void readFileContentThrowsIfFileOutsideProjectRoot() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path outsideFile = Files.createTempFile("outside", ".txt"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> fileTreeService.readFileContent(outsideFile.toString())); + assertTrue(exception.getMessage().contains("File is outside of projects root")); + + Files.deleteIfExists(outsideFile); + Files.deleteIfExists(projectRoot); + } + + @Test + void readFileContentThrowsIfFileDoesNotExist() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path missingFile = projectRoot.resolve("missing.xml"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + assertThrows(NoSuchFileException.class, () -> fileTreeService.readFileContent(missingFile.toString())); + + Files.deleteIfExists(projectRoot); + } + + @Test + void readFileContentThrowsIfPathIsDirectory() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path dir = Files.createTempDirectory(projectRoot, "subdir"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> fileTreeService.readFileContent(dir.toString())); + assertTrue(exception.getMessage().contains("Requested path is a directory")); + + Files.deleteIfExists(dir); + Files.deleteIfExists(projectRoot); + } + + @Test + void updateFileContentWritesNewContent() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path file = Files.createTempFile(projectRoot, "config", ".xml"); + + String initialContent = "old"; + Files.writeString(file, initialContent); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + String newContent = "updated"; + fileTreeService.updateFileContent(file.toString(), newContent); + + String readBack = Files.readString(file); + assertEquals(newContent, readBack); + + Files.deleteIfExists(file); + Files.deleteIfExists(projectRoot); + } + + @Test + void updateFileContentThrowsIfFileOutsideProjectRoot() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path outsideFile = Files.createTempFile("outside", ".txt"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.updateFileContent(outsideFile.toString(), "content")); + assertTrue(exception.getMessage().contains("File is outside of projects root")); + + Files.deleteIfExists(outsideFile); + Files.deleteIfExists(projectRoot); + } + + @Test + void updateFileContentThrowsIfFileDoesNotExist() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path missingFile = projectRoot.resolve("missing.xml"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.updateFileContent(missingFile.toString(), "content")); + assertTrue(exception.getMessage().contains("File does not exist")); + + Files.deleteIfExists(projectRoot); + } + + @Test + void updateFileContentThrowsIfPathIsDirectory() throws IOException { + Path projectRoot = Files.createTempDirectory("projectRoot"); + Path dir = Files.createTempDirectory(projectRoot, "subdir"); + + FileTreeService fileTreeService = new FileTreeService(projectRoot.toString()); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> fileTreeService.updateFileContent(dir.toString(), "content")); + assertTrue(exception.getMessage().contains("Cannot update a directory")); + + Files.deleteIfExists(dir); + Files.deleteIfExists(projectRoot); + } + + @Test + void getProjectTreeBuildsTreeCorrectly() throws IOException { + FileTreeNode tree = fileTreeService.getProjectTree("ProjectA"); + + assertEquals("ProjectA", tree.getName()); + assertEquals(1, tree.getChildren().size()); + assertEquals("config1.xml", tree.getChildren().get(0).getName()); + } + + @Test + void updateAdapterFromFileReturnsFalseIfInvalidXml() + throws IOException, AdapterNotFoundException, ConfigurationNotFoundException { + Path filePath = tempRoot.resolve("ProjectA/config1.xml"); + + // Provide malformed XML + boolean result = fileTreeService.updateAdapterFromFile("ProjectA", filePath, "MyAdapter", " fileTreeService.updateAdapterFromFile( + "ProjectA", filePath, "NonExistentAdapter", "")); + + assertTrue(thrown.getMessage().contains("Adapter not found")); + } + + @Test + void getProjectsRootReturnsCorrectPath() { + Path root = fileTreeService.getProjectsRoot(); + assertEquals(tempRoot.toAbsolutePath(), root); + } + + @Test + void getProjectsRootThrowsIfRootDoesNotExist() { + // Provide a path that does not exist + String nonExistentPath = "some/nonexistent/path"; + FileTreeService fileTreeService = new FileTreeService(nonExistentPath); + + IllegalStateException exception = assertThrows(IllegalStateException.class, fileTreeService::getProjectsRoot); + + assert exception.getMessage().contains("Projects root does not exist or is not a directory"); + } + + @Test + void getProjectsRootThrowsIfRootIsAFile() throws IOException { + // Create a temporary file (not a directory) + Path tempFile = Files.createTempFile("not-a-directory", ".txt"); + FileTreeService fileTreeService = new FileTreeService(tempFile.toString()); + + IllegalStateException exception = assertThrows(IllegalStateException.class, fileTreeService::getProjectsRoot); + + assert exception.getMessage().contains("Projects root does not exist or is not a directory"); + + // Cleanup + Files.deleteIfExists(tempFile); + } +} From b2fd17c6cfd72673834b7e97dbaf1c5544c52b1c Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Mon, 12 Jan 2026 17:07:35 +0100 Subject: [PATCH 09/16] Refactored traverse functions --- .../file-structure/editor-data-provider.ts | 25 +--- .../file-structure/files-data-provider.ts | 117 +++++++++--------- 2 files changed, 62 insertions(+), 80 deletions(-) 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 11923c7..831491b 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 @@ -40,8 +40,8 @@ export default class EditorFilesDataProvider implements TreeDataProvider { private buildTreeFromFileTree(rootNode: FileTreeNode) { const newData: Record> = {} - const traverse = (node: FileTreeNode, parentIndex: TreeItemIndex): TreeItemIndex => { - const index = parentIndex === 'root' ? node.name : `${parentIndex}/${node.name}` + const traverse = (node: FileTreeNode, parentIndex: TreeItemIndex | null): TreeItemIndex => { + const index = parentIndex === null ? 'root' : `${parentIndex}/${node.name}` newData[index] = { index, @@ -54,33 +54,16 @@ export default class EditorFilesDataProvider implements TreeDataProvider { } if (node.type === 'DIRECTORY' && node.children) { - newData[index].children ??= [] for (const child of node.children) { const childIndex = traverse(child, index) - newData[index].children.push(childIndex) + newData[index].children!.push(childIndex) } } return index } - newData['root'] = { - index: 'root', - data: { - name: rootNode.name, - path: rootNode.path, - }, - children: [], - isFolder: rootNode.type === 'DIRECTORY', - } - - if (rootNode.children) { - for (const child of rootNode.children) { - const childIndex = traverse(child, 'root') - newData['root'].children!.push(childIndex) - } - } - + traverse(rootNode, null) this.data = newData } diff --git a/src/main/frontend/app/components/file-structure/files-data-provider.ts b/src/main/frontend/app/components/file-structure/files-data-provider.ts index 9d70459..e2ecba5 100644 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/files-data-provider.ts @@ -16,87 +16,86 @@ export default class FilesDataProvider implements TreeDataProvider { /** Update the tree using a backend fileTree */ public async updateData(fileTree: FileTreeNode) { - const newData: Record = { - root: { - index: 'root', - data: 'Configurations', - children: [], - isFolder: true, - }, - } + const newData: Record = {} - // Recursive traversal - const traverse = async (node: FileTreeNode, parentIndex: TreeItemIndex): Promise => { - // Ignore non-XML files - if (node.type === 'FILE' && !node.name.endsWith('.xml')) return null + const traverse = async (node: FileTreeNode, parentIndex: TreeItemIndex | null): Promise => { + const index = parentIndex === null ? 'root' : `${parentIndex}/${node.name}` - const index = parentIndex === 'root' ? node.name : `${parentIndex}/${node.name}` + // Root node + if (parentIndex === null) { + newData[index] = { + index, + data: 'Configurations', + children: [], + isFolder: true, + } + } + + // Ignore non-XML files (but only for non-root nodes) + if (parentIndex !== null && node.type === 'FILE' && !node.name.endsWith('.xml')) { + return null + } - if (node.type === 'DIRECTORY') { + // Directory + if (parentIndex !== null && node.type === 'DIRECTORY') { newData[index] = { index, data: node.name, children: [], isFolder: true, } + } - if (node.children) { - for (const child of node.children) { - const childIndex = await traverse(child, index) - if (childIndex && !newData[index].children!.includes(childIndex)) { - newData[index].children!.push(childIndex) - } - } + // .xml treated as folder + if (parentIndex !== null && node.type === 'FILE') { + newData[index] = { + index, + data: node.name.replace(/\.xml$/, ''), + children: [], + isFolder: true, } - // Remove empty directories - if (newData[index].children!.length === 0) { - delete newData[index] - return null + try { + const adapterNames = await getAdapterNamesFromConfiguration(this.projectName, node.path) + + for (const adapterName of adapterNames) { + const adapterIndex = `${index}/${adapterName}` + newData[adapterIndex] = { + index: adapterIndex, + data: { + adapterName, + configPath: node.path, + listenerName: await getAdapterListenerType(this.projectName, node.path, adapterName), + }, + isFolder: false, + } + newData[index].children!.push(adapterIndex) + } + } catch (error) { + console.error(`Failed to load adapters for ${node.path}:`, error) } - - return index } - // FILE ending with .xml - newData[index] = { - index, - data: node.name.replace(/\.xml$/, ''), - children: [], - isFolder: true, // treat .xml as folder to hold adapters - } - - // Populate adapters using your shared function - try { - const adapterNames = await getAdapterNamesFromConfiguration(this.projectName, node.path) - - for (const adapterName of adapterNames) { - const adapterIndex = `${index}/${adapterName}` - newData[adapterIndex] = { - index: adapterIndex, - data: { - adapterName, - configPath: node.path, - listenerName: await getAdapterListenerType(this.projectName, node.path, adapterName), - }, - isFolder: false, // leaf node + // Recurse into children + if (node.type === 'DIRECTORY' && node.children) { + for (const child of node.children) { + const childIndex = await traverse(child, index) + if (childIndex) { + newData[index].children!.push(childIndex) } - newData[index].children!.push(adapterIndex) } - } catch (error) { - console.error(`Failed to load adapters for ${node.path}:`, error) + } + + // Prune empty non-root directories + if (parentIndex !== null && node.type === 'DIRECTORY' && newData[index].children!.length === 0) { + delete newData[index] + return null } return index } - // Traverse all children of the root folder - if (fileTree.children) { - for (const child of fileTree.children) { - const childIndex = await traverse(child, 'root') - if (childIndex) newData['root'].children!.push(childIndex) - } - } + await traverse(fileTree, null) this.data = newData this.notifyListeners(['root']) From 2187a373721526c75319920c3e22967e27768e8f Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Mon, 12 Jan 2026 19:30:22 +0100 Subject: [PATCH 10/16] Now caches provider data on first load, and only updates the cache if filetree from backend has changed --- .../file-structure/file-structure.tsx | 36 ++++++++++++++----- .../file-structure/files-data-provider.ts | 6 ++++ .../file-structure/tree-utilities.ts | 12 +++++++ .../app/routes/projectlanding/project-row.tsx | 6 ++-- src/main/frontend/app/stores/tree-store.ts | 32 ++++++++++------- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/file-structure.tsx b/src/main/frontend/app/components/file-structure/file-structure.tsx index 3fb71bf..7685273 100644 --- a/src/main/frontend/app/components/file-structure/file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/file-structure.tsx @@ -1,5 +1,4 @@ import React, { type JSX, useEffect, useRef, useState } from 'react' -import { getAdapterListenerType, getAdapterNamesFromConfiguration } from '~/routes/studio/xml-to-json-parser' import useTabStore from '~/stores/tab-store' import Search from '~/components/search/search' import FolderIcon from '../../../icons/solar/Folder.svg?react' @@ -18,8 +17,9 @@ import { } from 'react-complex-tree' import FilesDataProvider from '~/components/file-structure/files-data-provider' import { useProjectStore } from '~/stores/project-store' -import { getListenerIcon } from './tree-utilities' +import { getListenerIcon, hashFileTree } from './tree-utilities' import type { FileTreeNode } from './editor-data-provider' +import { useTreeStore } from '~/stores/tree-store' export interface ConfigWithAdapters { configPath: string @@ -72,18 +72,38 @@ export default function FileStructure() { const getTab = useTabStore((state) => state.getTab) useEffect(() => { - // Load in the filetree from the backend const loadFileTree = async () => { if (!project) return + setIsTreeLoading(true) + try { + // Fetch the raw file tree from the backend const response = await fetch(`/api/projects/${project.name}/tree`) - const tree: FileTreeNode = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch file tree: ${response.status}`) + } + const fetchedTree: FileTreeNode = await response.json() + + // Compute a hash of the fetched tree + const fetchedHash = hashFileTree(fetchedTree) - const configurationsRoot = findConfigurationsDir(tree) - if (!configurationsRoot) return + // Read cached provider data from the TreeStore + const { providerData: cachedProviderData, treeHash: cachedHash } = useTreeStore.getState() - await dataProviderReference.current.updateData(configurationsRoot) + // If hash matches and we have cached provider data, use it + if (cachedProviderData && fetchedHash === cachedHash) { + dataProviderReference.current.buildFromCachedData(cachedProviderData) + } else { + // Otherwise, filetree updated, so rebuild the provider data + const configurationsRoot = findConfigurationsDir(fetchedTree) + if (!configurationsRoot) return + + const newProviderData = await dataProviderReference.current.updateData(configurationsRoot) + + // Cache the newly built provider data and hash + useTreeStore.getState().setProviderData(newProviderData, fetchedHash) + } } catch (error) { console.error('Failed to load file tree', error) } finally { @@ -91,7 +111,7 @@ export default function FileStructure() { } } - loadFileTree() + void loadFileTree() }, [project]) useEffect(() => { diff --git a/src/main/frontend/app/components/file-structure/files-data-provider.ts b/src/main/frontend/app/components/file-structure/files-data-provider.ts index e2ecba5..891925e 100644 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/files-data-provider.ts @@ -99,6 +99,12 @@ export default class FilesDataProvider implements TreeDataProvider { this.data = newData this.notifyListeners(['root']) + return newData + } + + public buildFromCachedData(cachedData: Record) { + this.data = cachedData + this.notifyListeners(['root']) } public async getAllItems(): Promise { 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 a2660d4..b4d3b75 100644 --- a/src/main/frontend/app/components/file-structure/tree-utilities.ts +++ b/src/main/frontend/app/components/file-structure/tree-utilities.ts @@ -3,6 +3,7 @@ import JavaIcon from '../../../icons/solar/Cup Hot.svg?react' import MessageIcon from '../../../icons/solar/Chat Dots.svg?react' import MailIcon from '../../../icons/solar/Mailbox.svg?react' import FolderIcon from '../../../icons/solar/Folder.svg?react' +import type { FileTreeNode } from './editor-data-provider' export function getListenerIcon(listenerType: string | null) { if (!listenerType) return CodeIcon @@ -16,3 +17,14 @@ export function getListenerIcon(listenerType: string | null) { return listenerIconMap[listenerType] ?? CodeIcon } + +export function hashFileTree(node: FileTreeNode): string { + const normalize = (n: FileTreeNode): unknown => ({ + name: n.name, + path: n.path, + type: n.type, + children: n.children?.map((element) => normalize(element)) ?? [], + }) + + return JSON.stringify(normalize(node)) +} diff --git a/src/main/frontend/app/routes/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index e9b49f1..f3f52f3 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -1,9 +1,9 @@ import { useNavigate } from 'react-router' import { useProjectStore } from '~/stores/project-store' -import { useTreeStore } from '~/stores/tree-store' import KebabVerticalIcon from 'icons/solar/Kebab Vertical.svg?react' import useTabStore from '~/stores/tab-store' import type { Project } from '~/routes/projectlanding/project-landing' +import { useTreeStore } from '~/stores/tree-store' interface ProjectRowProperties { project: Project @@ -13,7 +13,7 @@ export default function ProjectRow({ project }: Readonly) const navigate = useNavigate() const setProject = useProjectStore((state) => state.setProject) - const clearConfigs = useTreeStore((state) => state.clearConfigs) + const clearTreeCache = useTreeStore((state) => state.clearCache) const clearTabs = useTabStore((state) => state.clearTabs) return ( @@ -21,8 +21,8 @@ export default function ProjectRow({ project }: Readonly) className="hover:bg-backdrop mb-2 flex w-full cursor-pointer items-center justify-between rounded px-3 py-1" onClick={() => { setProject(project) - clearConfigs() clearTabs() + clearTreeCache() navigate('/configurations') }} > diff --git a/src/main/frontend/app/stores/tree-store.ts b/src/main/frontend/app/stores/tree-store.ts index faa5cec..e0dba87 100644 --- a/src/main/frontend/app/stores/tree-store.ts +++ b/src/main/frontend/app/stores/tree-store.ts @@ -1,18 +1,24 @@ -import type { ConfigWithAdapters } from '~/components/file-structure/file-structure' +import type { TreeItem, TreeItemIndex } from 'react-complex-tree' import { create } from 'zustand' -interface TreestoreState { - configs: ConfigWithAdapters[] - isLoading: boolean - setConfigs: (configs: ConfigWithAdapters[]) => void - setIsLoading: (loading: boolean) => void - clearConfigs: () => void +interface TreeStoreState { + providerData: Record | null + treeHash: string | null + setProviderData: (data: Record, hash: string) => void + clearCache: () => void } -export const useTreeStore = create((set) => ({ - configs: [], - isLoading: true, - setConfigs: (configs) => set({ configs }), - setIsLoading: (isLoading) => set({ isLoading }), - clearConfigs: () => set({ configs: [], isLoading: true }), +export const useTreeStore = create((set) => ({ + providerData: null, + treeHash: null, + setProviderData: (data, hash) => + set({ + providerData: data, + treeHash: hash, + }), + clearCache: () => + set({ + providerData: null, + treeHash: null, + }), })) From f041e8296e9de3920503f06554f97b31f38b2d65 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Tue, 13 Jan 2026 15:03:02 +0100 Subject: [PATCH 11/16] Filetree now requests folderdata when expanded, instead of preloading the entire tree, which was expensive --- .../file-structure/editor-data-provider.ts | 100 ++++++++---- .../file-structure/editor-file-structure.tsx | 37 +++-- .../file-structure/files-data-provider.ts | 135 ---------------- ...tructure.tsx => studio-file-structure.tsx} | 118 ++++++-------- .../studio-files-data-provider.ts | 147 ++++++++++++++++++ .../file-structure/tree-utilities.ts | 20 ++- .../configurations/configuration-manager.tsx | 2 +- .../app/routes/projectlanding/project-row.tsx | 3 - .../frontend/app/routes/studio/studio.tsx | 4 +- src/main/frontend/app/stores/tree-store.ts | 24 --- .../flow/filetree/FileTreeService.java | 90 +++++++++-- .../flow/project/ProjectController.java | 31 +++- 12 files changed, 412 insertions(+), 299 deletions(-) delete mode 100644 src/main/frontend/app/components/file-structure/files-data-provider.ts rename src/main/frontend/app/components/file-structure/{file-structure.tsx => studio-file-structure.tsx} (74%) create mode 100644 src/main/frontend/app/components/file-structure/studio-files-data-provider.ts delete mode 100644 src/main/frontend/app/stores/tree-store.ts 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 831491b..a80aae2 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 @@ -16,55 +16,95 @@ export default class EditorFilesDataProvider implements TreeDataProvider { private data: Record> = {} private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] private readonly projectName: string + private loadedDirectories = new Set() constructor(projectName: string) { this.projectName = projectName - this.fetchAndBuildTree() + this.loadRoot() } - /** Fetch file tree from backend and build the provider's data */ - private async fetchAndBuildTree() { + private async loadRoot() { try { const response = await fetch(`/api/projects/${this.projectName}/tree`) if (!response.ok) throw new Error(`HTTP error ${response.status}`) - const tree: FileTreeNode = await response.json() - this.buildTreeFromFileTree(tree) + const root: FileTreeNode = await response.json() + + this.data['root'] = { + index: 'root', + data: { name: root.name, path: root.path }, + isFolder: true, + children: [], + } + + // Sort directories first, then files, both alphabetically + const sortedChildren = (root.children ?? []).toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === 'DIRECTORY' ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + for (const child of sortedChildren) { + const childIndex = `root/${child.name}` + + this.data[childIndex] = { + index: childIndex, + data: { name: child.name, path: child.path }, + isFolder: child.type === 'DIRECTORY', + children: child.type === 'DIRECTORY' ? [] : undefined, + } + + this.data['root'].children!.push(childIndex) + } + + this.loadedDirectories.add(root.path) this.notifyListeners(['root']) } catch (error) { - console.error('Failed to load project tree for EditorFilesDataProvider', error) + console.error('Failed to load root directory', error) } } - /** Converts the backend file tree to react-complex-tree data */ - private buildTreeFromFileTree(rootNode: FileTreeNode) { - const newData: Record> = {} - - const traverse = (node: FileTreeNode, parentIndex: TreeItemIndex | null): TreeItemIndex => { - const index = parentIndex === null ? 'root' : `${parentIndex}/${node.name}` - - newData[index] = { - index, - data: { - name: node.name, - path: node.path, - }, - children: node.type === 'DIRECTORY' ? [] : undefined, - isFolder: node.type === 'DIRECTORY', - } + public async loadDirectory(itemId: TreeItemIndex): Promise { + const item = this.data[itemId] + if (!item || !item.isFolder) return + if (this.loadedDirectories.has(item.data.path)) return - if (node.type === 'DIRECTORY' && node.children) { - for (const child of node.children) { - const childIndex = traverse(child, index) - newData[index].children!.push(childIndex) + try { + const response = await fetch(`/api/projects/${this.projectName}?path=${encodeURIComponent(item.data.path)}`) + if (!response.ok) throw new Error('Failed to fetch directory') + + const dir: FileTreeNode = await response.json() + + const sortedChildren = (dir.children ?? []).toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === 'DIRECTORY' ? -1 : 1 } + return a.name.localeCompare(b.name) + }) + + const children: TreeItemIndex[] = [] + + for (const child of sortedChildren) { + const childIndex = `${itemId}/${child.name}` + + this.data[childIndex] = { + index: childIndex, + data: { name: child.name, path: child.path }, + isFolder: child.type === 'DIRECTORY', + children: child.type === 'DIRECTORY' ? [] : undefined, + } + + children.push(childIndex) } - return index - } + item.children = children - traverse(rootNode, null) - this.data = newData + this.loadedDirectories.add(item.data.path) + this.notifyListeners([itemId]) + } catch (error) { + console.error('Failed to load directory', error) + } } public async getAllItems(): Promise[]> { diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 5ca0722..a13e5f9 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -1,4 +1,4 @@ -import React, { type JSX, useCallback, useEffect, useRef, useState } from 'react' +import React, { type JSX, useEffect, useRef, useState } from 'react' import Search from '~/components/search/search' import FolderIcon from '../../../icons/solar/Folder.svg?react' import FolderOpenIcon from '../../../icons/solar/Folder Open.svg?react' @@ -79,7 +79,7 @@ export default function EditorFileStructure() { } findMatchingItems() - }, [searchTerm, filepaths]) + }, [searchTerm, filepaths, dataProvider]) const openFileTab = (filePath: string, fileName: string) => { if (!getTab(filePath)) { @@ -99,15 +99,17 @@ export default function EditorFileStructure() { if (!dataProvider || itemIds.length === 0) return const itemId = itemIds[0] - if (typeof itemId !== 'string') return - const item = await dataProvider.getTreeItem(itemId) - if (!item || item.isFolder) return + if (!item) return - const filePath = item.data.path - const fileName = item.data.name + // Fetch contents and expand folder if folder + if (item.isFolder) { + await dataProvider.loadDirectory(itemId) + return + } - openFileTab(filePath, fileName) + // Load file in editor tab if file + openFileTab(item.data.path, item.data.name) } /* Keyboard navigation */ @@ -164,12 +166,19 @@ export default function EditorFileStructure() { const renderItemArrow = ({ item, context }: { item: TreeItem; context: TreeItemRenderContext }) => { if (!item.isFolder) return null const Icon = context.isExpanded ? AltArrowDownIcon : AltArrowRightIcon - return ( - - ) + + const handleClick = async (event: React.MouseEvent) => { + event.stopPropagation() + + // Only load when expanding + if (!context.isExpanded && dataProvider) { + await dataProvider.loadDirectory(item.index) + } + + context.toggleExpandedState() + } + + return } const renderItemTitle = ({ diff --git a/src/main/frontend/app/components/file-structure/files-data-provider.ts b/src/main/frontend/app/components/file-structure/files-data-provider.ts deleted file mode 100644 index 891925e..0000000 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' -import type { FileTreeNode } from './editor-data-provider' -import { getAdapterListenerType, getAdapterNamesFromConfiguration } from '~/routes/studio/xml-to-json-parser' - -export default class FilesDataProvider implements TreeDataProvider { - private data: Record = {} - private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] - private projectName: string - - constructor(projectName: string, fileTree?: FileTreeNode) { - this.projectName = projectName - if (fileTree) { - void this.updateData(fileTree) - } - } - - /** Update the tree using a backend fileTree */ - public async updateData(fileTree: FileTreeNode) { - const newData: Record = {} - - const traverse = async (node: FileTreeNode, parentIndex: TreeItemIndex | null): Promise => { - const index = parentIndex === null ? 'root' : `${parentIndex}/${node.name}` - - // Root node - if (parentIndex === null) { - newData[index] = { - index, - data: 'Configurations', - children: [], - isFolder: true, - } - } - - // Ignore non-XML files (but only for non-root nodes) - if (parentIndex !== null && node.type === 'FILE' && !node.name.endsWith('.xml')) { - return null - } - - // Directory - if (parentIndex !== null && node.type === 'DIRECTORY') { - newData[index] = { - index, - data: node.name, - children: [], - isFolder: true, - } - } - - // .xml treated as folder - if (parentIndex !== null && node.type === 'FILE') { - newData[index] = { - index, - data: node.name.replace(/\.xml$/, ''), - children: [], - isFolder: true, - } - - try { - const adapterNames = await getAdapterNamesFromConfiguration(this.projectName, node.path) - - for (const adapterName of adapterNames) { - const adapterIndex = `${index}/${adapterName}` - newData[adapterIndex] = { - index: adapterIndex, - data: { - adapterName, - configPath: node.path, - listenerName: await getAdapterListenerType(this.projectName, node.path, adapterName), - }, - isFolder: false, - } - newData[index].children!.push(adapterIndex) - } - } catch (error) { - console.error(`Failed to load adapters for ${node.path}:`, error) - } - } - - // Recurse into children - if (node.type === 'DIRECTORY' && node.children) { - for (const child of node.children) { - const childIndex = await traverse(child, index) - if (childIndex) { - newData[index].children!.push(childIndex) - } - } - } - - // Prune empty non-root directories - if (parentIndex !== null && node.type === 'DIRECTORY' && newData[index].children!.length === 0) { - delete newData[index] - return null - } - - return index - } - - await traverse(fileTree, null) - - this.data = newData - this.notifyListeners(['root']) - return newData - } - - public buildFromCachedData(cachedData: Record) { - this.data = cachedData - this.notifyListeners(['root']) - } - - public async getAllItems(): Promise { - return Object.values(this.data) - } - - public async getTreeItem(itemId: TreeItemIndex) { - return this.data[itemId] - } - - public async onChangeItemChildren(itemId: TreeItemIndex, newChildren: TreeItemIndex[]) { - this.data[itemId].children = newChildren - this.notifyListeners([itemId]) - } - - public onDidChangeTreeData(listener: (changedItemIds: TreeItemIndex[]) => void): Disposable { - this.treeChangeListeners.push(listener) - return { - dispose: () => { - this.treeChangeListeners.splice(this.treeChangeListeners.indexOf(listener), 1) - }, - } - } - - private notifyListeners(itemIds: TreeItemIndex[]) { - for (const listener of this.treeChangeListeners) listener(itemIds) - } -} diff --git a/src/main/frontend/app/components/file-structure/file-structure.tsx b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx similarity index 74% rename from src/main/frontend/app/components/file-structure/file-structure.tsx rename to src/main/frontend/app/components/file-structure/studio-file-structure.tsx index 7685273..2ca2d8b 100644 --- a/src/main/frontend/app/components/file-structure/file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx @@ -15,11 +15,10 @@ import { type TreeItemIndex, UncontrolledTreeEnvironment, } from 'react-complex-tree' -import FilesDataProvider from '~/components/file-structure/files-data-provider' +import FilesDataProvider from '~/components/file-structure/studio-files-data-provider' import { useProjectStore } from '~/stores/project-store' -import { getListenerIcon, hashFileTree } from './tree-utilities' -import type { FileTreeNode } from './editor-data-provider' -import { useTreeStore } from '~/stores/tree-store' +import { getListenerIcon } from './tree-utilities' +import type { FileNode } from './editor-data-provider' export interface ConfigWithAdapters { configPath: string @@ -31,33 +30,21 @@ export interface ConfigWithAdapters { const TREE_ID = 'studio-files-tree' -function getItemTitle(item: TreeItem): string { - // item.data is either a string (for folders) or object (for leaf nodes) +function getItemTitle(item: TreeItem): string { if (typeof item.data === 'string') { return item.data - } else if (typeof item.data === 'object' && item.data !== null && 'adapterName' in item.data) { - return (item.data as { adapterName: string }).adapterName + } else if (typeof item.data === 'object' && item.data !== null) { + if ('adapterName' in item.data) { + return (item.data as { adapterName: string }).adapterName + } + if ('name' in item.data) { + return (item.data as { name: string }).name + } } return 'Unnamed' } -function findConfigurationsDir(node: FileTreeNode): FileTreeNode | null { - const normalizedPath = node.path.replaceAll('\\', '/') - if (node.type === 'DIRECTORY' && normalizedPath.endsWith('/src/main/configurations')) { - return node - } - - if (!node.children) return null - - for (const child of node.children) { - const found = findConfigurationsDir(child) - if (found) return found - } - - return null -} - -export default function FileStructure() { +export default function StudioFileStructure() { const project = useProjectStore.getState().project const [isTreeLoading, setIsTreeLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -78,32 +65,9 @@ export default function FileStructure() { setIsTreeLoading(true) try { - // Fetch the raw file tree from the backend - const response = await fetch(`/api/projects/${project.name}/tree`) - if (!response.ok) { - throw new Error(`Failed to fetch file tree: ${response.status}`) - } - const fetchedTree: FileTreeNode = await response.json() - - // Compute a hash of the fetched tree - const fetchedHash = hashFileTree(fetchedTree) - - // Read cached provider data from the TreeStore - const { providerData: cachedProviderData, treeHash: cachedHash } = useTreeStore.getState() - - // If hash matches and we have cached provider data, use it - if (cachedProviderData && fetchedHash === cachedHash) { - dataProviderReference.current.buildFromCachedData(cachedProviderData) - } else { - // Otherwise, filetree updated, so rebuild the provider data - const configurationsRoot = findConfigurationsDir(fetchedTree) - if (!configurationsRoot) return - - const newProviderData = await dataProviderReference.current.updateData(configurationsRoot) - - // Cache the newly built provider data and hash - useTreeStore.getState().setProviderData(newProviderData, fetchedHash) - } + // Create a new provider for this project + const provider = new FilesDataProvider(project.name) + dataProviderReference.current = provider } catch (error) { console.error('Failed to load file tree', error) } finally { @@ -128,8 +92,8 @@ export default function FileStructure() { const lower = searchTerm.toLowerCase() const matches = allItems - .filter((item: TreeItem) => getItemTitle(item).toLowerCase().includes(lower)) - .map((item: TreeItem) => String(item.index)) + .filter((item: TreeItem) => getItemTitle(item).toLowerCase().includes(lower)) + .map((item: TreeItem) => String(item.index)) setMatchingItemIds(matches) @@ -153,23 +117,38 @@ export default function FileStructure() { if (!dataProviderReference.current || itemIds.length === 0) return const itemId = itemIds[0] - if (typeof itemId !== 'string') return const item = await dataProviderReference.current.getTreeItem(itemId) + if (!item) return - if (!item || item.isFolder) return + if (item.isFolder) { + await loadFolderContents(item) + return + } + // Leaf node: open adapter const data = item.data if (typeof data === 'object' && data !== null && 'adapterName' in data && 'configPath' in data) { - const { adapterName, configPath } = data as { - adapterName: string - configPath: string - } + const { adapterName, configPath } = data as { adapterName: string; configPath: string } openNewTab(adapterName, configPath) } } + const loadFolderContents = async (item: TreeItem) => { + if (!item.isFolder) return + + const path = item.data.path + + if (path.endsWith('.xml')) { + // XML configs can contain adapters + await dataProviderReference.current.loadAdapters(item.index) + } else { + // Normal directory + await dataProviderReference.current.loadDirectory(item.index) + } + } + const openNewTab = (adapterName: string, configPath: string) => { if (!getTab(adapterName)) { setTabData(adapterName, { @@ -250,16 +229,19 @@ export default function FileStructure() { treeReference.expandAll() } - const renderItemArrow = ({ item, context }: { item: TreeItem; context: TreeItemRenderContext }) => { - if (!item.isFolder) { - return null - } + const renderItemArrow = ({ item, context }: { item: TreeItem; context: TreeItemRenderContext }) => { + if (!item.isFolder) return null + const Icon = context.isExpanded ? AltArrowDownIcon : AltArrowRightIcon + + const handleArrowClick = async (event: React.MouseEvent) => { + event.stopPropagation() // prevent triggering item click + await loadFolderContents(item) + context.toggleExpandedState() + } + return ( - + ) } @@ -269,7 +251,7 @@ export default function FileStructure() { context, }: { title: string - item: TreeItem + item: TreeItem context: TreeItemRenderContext }) => { const searchLower = searchTerm.toLowerCase() 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 new file mode 100644 index 0000000..53962e4 --- /dev/null +++ b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts @@ -0,0 +1,147 @@ +import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' +import type { FileTreeNode } from './editor-data-provider' +import { getAdapterListenerType, getAdapterNamesFromConfiguration } from '~/routes/studio/xml-to-json-parser' +import { sortChildren } from './tree-utilities' + +export default class FilesDataProvider implements TreeDataProvider { + private data: Record = {} + private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] + private projectName: string + private loadedDirectories = new Set() + + constructor(projectName: string) { + this.projectName = projectName + void this.loadRoot() + } + + /** Update the tree using a backend fileTree */ + private async loadRoot() { + const response = await fetch(`/api/projects/${this.projectName}/tree/configurations?shallow=true`) + if (!response.ok) throw new Error(`Failed to fetch root: ${response.status}`) + + const root: FileTreeNode = await response.json() + + this.data['root'] = { + index: 'root', + data: 'Configurations', + children: [], + isFolder: true, + } + + const sortedChildren = sortChildren(root.children) + + for (const child of sortedChildren) { + const index = `root/${child.name}` + + this.data[index] = { + index, + data: { + name: child.type === 'DIRECTORY' ? child.name : child.name.replace(/\.xml$/, ''), + path: child.path, + }, + children: child.type === 'DIRECTORY' || child.name.endsWith('.xml') ? [] : undefined, + isFolder: true, + } + + this.data['root'].children!.push(index) + } + + this.loadedDirectories.add(root.path) + this.notifyListeners(['root']) + } + + public async loadDirectory(itemId: TreeItemIndex) { + const item = this.data[itemId] + if (!item || !item.isFolder || this.loadedDirectories.has(item.data.path)) return + + try { + if (!item.children) item.children = [] + + const response = await fetch(`/api/projects/${this.projectName}?path=${encodeURIComponent(item.data.path)}`) + if (!response.ok) throw new Error('Failed to fetch directory') + + const dir: FileTreeNode = await response.json() + + const sortedChildren = sortChildren(dir.children) + + const children: TreeItemIndex[] = [] + + for (const child of sortedChildren) { + const childIndex = `${itemId}/${child.name}` + const isFolder = child.type === 'DIRECTORY' || child.name.endsWith('.xml') + + this.data[childIndex] = { + index: childIndex, + data: { + name: isFolder ? child.name.replace(/\.xml$/, '') : child.name, + path: child.path, + }, + isFolder, + children: isFolder ? [] : undefined, + } + + children.push(childIndex) + } + + item.children = children + this.loadedDirectories.add(item.data.path) + this.notifyListeners([itemId]) + } catch (error) { + console.error(`Failed to load directory for ${item.data.path}`, error) + } + } + + public async loadAdapters(itemId: TreeItemIndex) { + const item = this.data[itemId] + if (!item || !item.isFolder || this.loadedDirectories.has(item.data.path)) return + + try { + const adapterNames = await getAdapterNamesFromConfiguration(this.projectName, item.data.path) + + for (const adapterName of adapterNames) { + const adapterIndex = `${itemId}/${adapterName}` + this.data[adapterIndex] = { + index: adapterIndex, + data: { + adapterName, + configPath: item.data.path, + listenerName: await getAdapterListenerType(this.projectName, item.data.path, adapterName), + }, + isFolder: false, + } + item.children!.push(adapterIndex) + } + + this.loadedDirectories.add(item.data.path) + this.notifyListeners([itemId]) + } catch (error) { + console.error(`Failed to load adapters for ${item.data.path}`, error) + } + } + + public async getAllItems(): Promise { + return Object.values(this.data) + } + + public async getTreeItem(itemId: TreeItemIndex) { + return this.data[itemId] + } + + public async onChangeItemChildren(itemId: TreeItemIndex, newChildren: TreeItemIndex[]) { + this.data[itemId].children = newChildren + this.notifyListeners([itemId]) + } + + public onDidChangeTreeData(listener: (changedItemIds: TreeItemIndex[]) => void): Disposable { + this.treeChangeListeners.push(listener) + return { + dispose: () => { + this.treeChangeListeners.splice(this.treeChangeListeners.indexOf(listener), 1) + }, + } + } + + private notifyListeners(itemIds: TreeItemIndex[]) { + for (const listener of this.treeChangeListeners) listener(itemIds) + } +} 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 b4d3b75..79b4f82 100644 --- a/src/main/frontend/app/components/file-structure/tree-utilities.ts +++ b/src/main/frontend/app/components/file-structure/tree-utilities.ts @@ -18,13 +18,17 @@ export function getListenerIcon(listenerType: string | null) { return listenerIconMap[listenerType] ?? CodeIcon } -export function hashFileTree(node: FileTreeNode): string { - const normalize = (n: FileTreeNode): unknown => ({ - name: n.name, - path: n.path, - type: n.type, - children: n.children?.map((element) => normalize(element)) ?? [], - }) +function getSortRank(child: FileTreeNode) { + if (child.type === 'DIRECTORY') return 0 + if (child.type === 'FILE' && child.name.endsWith('.xml')) return 1 + return 2 +} - return JSON.stringify(normalize(node)) +export function sortChildren(children?: FileTreeNode[]): FileTreeNode[] { + // Sort directories first, then XML files (Treated like folders), then other files, all alphabetically + return (children ?? []).toSorted((a, b) => { + const diff = getSortRank(a) - getSortRank(b) + if (diff !== 0) return diff + return a.name.localeCompare(b.name) + }) } diff --git a/src/main/frontend/app/routes/configurations/configuration-manager.tsx b/src/main/frontend/app/routes/configurations/configuration-manager.tsx index ed02c7f..4287392 100644 --- a/src/main/frontend/app/routes/configurations/configuration-manager.tsx +++ b/src/main/frontend/app/routes/configurations/configuration-manager.tsx @@ -56,7 +56,7 @@ export default function ConfigurationManager() { const fetchTree = async () => { try { - const response = await fetch(`/api/projects/${currentProject.name}/tree`) + const response = await fetch(`/api/projects/${currentProject.name}/tree/configurations`) if (!response.ok) { throw new Error(`HTTP error ${response.status}`) } diff --git a/src/main/frontend/app/routes/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index f3f52f3..7154c66 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -3,7 +3,6 @@ import { useProjectStore } from '~/stores/project-store' import KebabVerticalIcon from 'icons/solar/Kebab Vertical.svg?react' import useTabStore from '~/stores/tab-store' import type { Project } from '~/routes/projectlanding/project-landing' -import { useTreeStore } from '~/stores/tree-store' interface ProjectRowProperties { project: Project @@ -13,7 +12,6 @@ export default function ProjectRow({ project }: Readonly) const navigate = useNavigate() const setProject = useProjectStore((state) => state.setProject) - const clearTreeCache = useTreeStore((state) => state.clearCache) const clearTabs = useTabStore((state) => state.clearTabs) return ( @@ -22,7 +20,6 @@ export default function ProjectRow({ project }: Readonly) onClick={() => { setProject(project) clearTabs() - clearTreeCache() navigate('/configurations') }} > diff --git a/src/main/frontend/app/routes/studio/studio.tsx b/src/main/frontend/app/routes/studio/studio.tsx index 3f7d3bd..656053a 100644 --- a/src/main/frontend/app/routes/studio/studio.tsx +++ b/src/main/frontend/app/routes/studio/studio.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import StudioTabs from '~/components/tabs/studio-tabs' -import FileStructure from '~/components/file-structure/file-structure' +import StudioFileStructure from '~/components/file-structure/studio-file-structure' import StudioContext from '~/routes/studio/context/studio-context' import Flow from '~/routes/studio/canvas/flow' import NodeContext from '~/routes/studio/context/node-context' @@ -30,7 +30,7 @@ export default function Studio() { <> - + <>
diff --git a/src/main/frontend/app/stores/tree-store.ts b/src/main/frontend/app/stores/tree-store.ts deleted file mode 100644 index e0dba87..0000000 --- a/src/main/frontend/app/stores/tree-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TreeItem, TreeItemIndex } from 'react-complex-tree' -import { create } from 'zustand' - -interface TreeStoreState { - providerData: Record | null - treeHash: string | null - setProviderData: (data: Record, hash: string) => void - clearCache: () => void -} - -export const useTreeStore = create((set) => ({ - providerData: null, - treeHash: null, - setProviderData: (data, hash) => - set({ - providerData: data, - treeHash: hash, - }), - clearCache: () => - set({ - providerData: null, - treeHash: null, - }), -})) diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 2c7577f..25bb006 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -94,7 +94,43 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { throw new IllegalArgumentException("Project does not exist: " + projectName); } - return buildTree(projectPath); + return buildShallowTree(projectPath); + } + + public FileTreeNode getShallowDirectoryTree(String projectName, String directoryPath) throws IOException { + Path dirPath = projectsRoot.resolve(projectName).resolve(directoryPath).normalize(); + + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { + throw new IllegalArgumentException("Directory does not exist: " + dirPath); + } + + return buildShallowTree(dirPath); + } + + public FileTreeNode getShallowConfigurationsDirectoryTree(String projectName) throws IOException { + Path configDirPath = projectsRoot + .resolve(projectName) + .resolve("src/main/configurations") + .normalize(); + + if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { + throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + } + + return buildShallowTree(configDirPath); + } + + public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IOException { + Path configDirPath = projectsRoot + .resolve(projectName) + .resolve("src/main/configurations") + .normalize(); + + if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { + throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + } + + return buildTree(configDirPath); } public boolean updateAdapterFromFile( @@ -107,8 +143,8 @@ public boolean updateAdapterFromFile( try { // Parse configuration XML from file - Document configDoc = - XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(configurationFile)); + Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() + .parse(Files.newInputStream(configurationFile)); // Parse new adapter XML Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() @@ -140,7 +176,7 @@ public boolean updateAdapterFromFile( } } - // Recursive method to build the file tree + // Recursive method to build the entire file tree private FileTreeNode buildTree(Path path) throws IOException { FileTreeNode node = new FileTreeNode(); node.setName(path.getFileName().toString()); @@ -151,12 +187,12 @@ private FileTreeNode buildTree(Path path) throws IOException { try (Stream stream = Files.list(path)) { List children = stream.map(p -> { - try { - return buildTree(p); - } catch (IOException e) { - throw new RuntimeException(e); - } - }) + try { + return buildTree(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) .collect(Collectors.toList()); node.setChildren(children); @@ -168,4 +204,38 @@ private FileTreeNode buildTree(Path path) throws IOException { return node; } + + // Method to build a shallow tree (only immediate children) + private FileTreeNode buildShallowTree(Path path) throws IOException { + FileTreeNode node = new FileTreeNode(); + node.setName(path.getFileName().toString()); + node.setPath(path.toAbsolutePath().toString()); + + if (!Files.isDirectory(path)) { + throw new IllegalArgumentException("Path is not a directory: " + path); + } + + node.setType(NodeType.DIRECTORY); + + try (Stream stream = Files.list(path)) { + List children = stream.map(p -> { + FileTreeNode child = new FileTreeNode(); + child.setName(p.getFileName().toString()); + child.setPath(p.toAbsolutePath().toString()); + + if (Files.isDirectory(p)) { + child.setType(NodeType.DIRECTORY); + } else { + child.setType(NodeType.FILE); + } + + return child; + }).toList(); + + node.setChildren(children); + } + + return node; + } + } diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index c3d2ed9..22a9c22 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController() @@ -66,6 +67,18 @@ public FileTreeNode getProjectTree(@PathVariable String name) throws IOException return fileTreeService.getProjectTree(name); } + @GetMapping("/{name}/tree/configurations") + public FileTreeNode getConfigurationTree( + @PathVariable String name, + @RequestParam(required = false, defaultValue = "false") boolean shallow) throws IOException { + + if (shallow) { + return fileTreeService.getShallowConfigurationsDirectoryTree(name); + } else { + return fileTreeService.getConfigurationsDirectoryTree(name); + } + } + @GetMapping("/{projectName}") public ResponseEntity getProject(@PathVariable String projectName) throws ProjectNotFoundException { @@ -76,6 +89,14 @@ public ResponseEntity getProject(@PathVariable String projectName) t return ResponseEntity.ok(dto); } + @GetMapping(value = "/{projectname}", params = "path") + public FileTreeNode getDirectoryContent( + @PathVariable String projectname, + @RequestParam String path) throws IOException { + + return fileTreeService.getShallowDirectoryTree(projectname, path); + } + @PatchMapping("/{projectname}") public ResponseEntity patchProject( @PathVariable String projectname, @RequestBody ProjectDTO projectDTO) { @@ -106,7 +127,8 @@ public ResponseEntity patchProject( FilterType type = entry.getKey(); Boolean enabled = entry.getValue(); - if (enabled == null) continue; + if (enabled == null) + continue; if (enabled) { project.enableFilter(type); @@ -152,7 +174,8 @@ public ResponseEntity importConfigurations( @PathVariable String projectname, @RequestBody ProjectImportDTO importDTO) { Project project = projectService.getProject(projectname); - if (project == null) return ResponseEntity.notFound().build(); + if (project == null) + return ResponseEntity.notFound().build(); for (ImportConfigurationDTO conf : importDTO.configurations()) { Configuration c = new Configuration(conf.filepath()); @@ -189,8 +212,8 @@ public ResponseEntity updateAdapterFromFile( @PathVariable String projectName, @RequestBody AdapterUpdateDTO dto) { Path configPath = Paths.get(dto.configurationPath()); - boolean updated = - fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), dto.adapterXml()); + boolean updated = fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), + dto.adapterXml()); if (!updated) { return ResponseEntity.notFound().build(); From c1bc0e1666989a8d7e1a8c3a528f460b022d7339 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Tue, 13 Jan 2026 15:17:35 +0100 Subject: [PATCH 12/16] Merge changes --- .../file-structure/studio-file-structure.tsx | 21 ---------------- .../flow/filetree/FileTreeService.java | 25 ------------------- 2 files changed, 46 deletions(-) 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 e78349d..d4bf379 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 @@ -44,26 +44,9 @@ function getItemTitle(item: TreeItem): string { return 'Unnamed' } -function findConfigurationsDir(node: FileTreeNode): FileTreeNode | null { - const normalizedPath = node.path.replaceAll('\\', '/') - if (node.type === 'DIRECTORY' && normalizedPath.endsWith('/src/main/configurations')) { - return node - } - - if (!node.children) return null - - for (const child of node.children) { - const found = findConfigurationsDir(child) - if (found) return found - } - - return null -} - export default function StudioFileStructure() { const project = useProjectStore.getState().project const [isTreeLoading, setIsTreeLoading] = useState(true) - const [isTreeLoading, setIsTreeLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [matchingItemIds, setMatchingItemIds] = useState([]) const [activeMatchIndex, setActiveMatchIndex] = useState(-1) @@ -71,7 +54,6 @@ export default function StudioFileStructure() { const tree = useRef(null) const dataProviderReference = useRef(new FilesDataProvider(project ? project.name : '')) - const dataProviderReference = useRef(new FilesDataProvider(project ? project.name : '')) const setTabData = useTabStore((state) => state.setTabData) const setActiveTab = useTabStore((state) => state.setActiveTab) const getTab = useTabStore((state) => state.getTab) @@ -128,7 +110,6 @@ export default function StudioFileStructure() { findMatchingItems() }, [searchTerm]) - }, [searchTerm]) const handleItemClick = (items: TreeItemIndex[], _treeId: string): void => { void handleItemClickAsync(items) @@ -328,8 +309,6 @@ export default function StudioFileStructure() { if (!project) { return

Loading project...

} - - if (isTreeLoading) { if (!project) { return

Loading project...

} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index a0e5d30..25bb006 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -94,7 +94,6 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { throw new IllegalArgumentException("Project does not exist: " + projectName); } -<<<<<<< HEAD return buildShallowTree(projectPath); } @@ -132,9 +131,6 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } return buildTree(configDirPath); -======= - return buildTree(projectPath); ->>>>>>> master } public boolean updateAdapterFromFile( @@ -147,13 +143,8 @@ public boolean updateAdapterFromFile( try { // Parse configuration XML from file -<<<<<<< HEAD Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() .parse(Files.newInputStream(configurationFile)); -======= - Document configDoc = - XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(configurationFile)); ->>>>>>> master // Parse new adapter XML Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() @@ -185,11 +176,7 @@ public boolean updateAdapterFromFile( } } -<<<<<<< HEAD // Recursive method to build the entire file tree -======= - // Recursive method to build the file tree ->>>>>>> master private FileTreeNode buildTree(Path path) throws IOException { FileTreeNode node = new FileTreeNode(); node.setName(path.getFileName().toString()); @@ -200,21 +187,12 @@ private FileTreeNode buildTree(Path path) throws IOException { try (Stream stream = Files.list(path)) { List children = stream.map(p -> { -<<<<<<< HEAD try { return buildTree(p); } catch (IOException e) { throw new RuntimeException(e); } }) -======= - try { - return buildTree(p); - } catch (IOException e) { - throw new RuntimeException(e); - } - }) ->>>>>>> master .collect(Collectors.toList()); node.setChildren(children); @@ -226,7 +204,6 @@ private FileTreeNode buildTree(Path path) throws IOException { return node; } -<<<<<<< HEAD // Method to build a shallow tree (only immediate children) private FileTreeNode buildShallowTree(Path path) throws IOException { @@ -261,6 +238,4 @@ private FileTreeNode buildShallowTree(Path path) throws IOException { return node; } -======= ->>>>>>> master } From 1e7b306a6db993fc63c149d6435f66e0386c7899 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Tue, 13 Jan 2026 16:08:29 +0100 Subject: [PATCH 13/16] Pipeline and security fixes --- .../flow/filetree/FileTreeService.java | 42 ++++++++++--------- .../flow/project/ProjectController.java | 19 ++++----- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 25bb006..9d29a1d 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -100,6 +100,10 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { public FileTreeNode getShallowDirectoryTree(String projectName, String directoryPath) throws IOException { Path dirPath = projectsRoot.resolve(projectName).resolve(directoryPath).normalize(); + if (!dirPath.startsWith(projectsRoot.resolve(projectName))) { + throw new SecurityException("Invalid path: outside project directory"); + } + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { throw new IllegalArgumentException("Directory does not exist: " + dirPath); } @@ -143,8 +147,8 @@ public boolean updateAdapterFromFile( try { // Parse configuration XML from file - Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() - .parse(Files.newInputStream(configurationFile)); + Document configDoc = + XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(configurationFile)); // Parse new adapter XML Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() @@ -187,12 +191,12 @@ private FileTreeNode buildTree(Path path) throws IOException { try (Stream stream = Files.list(path)) { List children = stream.map(p -> { - try { - return buildTree(p); - } catch (IOException e) { - throw new RuntimeException(e); - } - }) + try { + return buildTree(p); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) .collect(Collectors.toList()); node.setChildren(children); @@ -219,23 +223,23 @@ private FileTreeNode buildShallowTree(Path path) throws IOException { try (Stream stream = Files.list(path)) { List children = stream.map(p -> { - FileTreeNode child = new FileTreeNode(); - child.setName(p.getFileName().toString()); - child.setPath(p.toAbsolutePath().toString()); + FileTreeNode child = new FileTreeNode(); + child.setName(p.getFileName().toString()); + child.setPath(p.toAbsolutePath().toString()); - if (Files.isDirectory(p)) { - child.setType(NodeType.DIRECTORY); - } else { - child.setType(NodeType.FILE); - } + if (Files.isDirectory(p)) { + child.setType(NodeType.DIRECTORY); + } else { + child.setType(NodeType.FILE); + } - return child; - }).toList(); + return child; + }) + .toList(); node.setChildren(children); } return node; } - } diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 22a9c22..523aad7 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -69,8 +69,8 @@ public FileTreeNode getProjectTree(@PathVariable String name) throws IOException @GetMapping("/{name}/tree/configurations") public FileTreeNode getConfigurationTree( - @PathVariable String name, - @RequestParam(required = false, defaultValue = "false") boolean shallow) throws IOException { + @PathVariable String name, @RequestParam(required = false, defaultValue = "false") boolean shallow) + throws IOException { if (shallow) { return fileTreeService.getShallowConfigurationsDirectoryTree(name); @@ -90,9 +90,8 @@ public ResponseEntity getProject(@PathVariable String projectName) t } @GetMapping(value = "/{projectname}", params = "path") - public FileTreeNode getDirectoryContent( - @PathVariable String projectname, - @RequestParam String path) throws IOException { + public FileTreeNode getDirectoryContent(@PathVariable String projectname, @RequestParam String path) + throws IOException { return fileTreeService.getShallowDirectoryTree(projectname, path); } @@ -127,8 +126,7 @@ public ResponseEntity patchProject( FilterType type = entry.getKey(); Boolean enabled = entry.getValue(); - if (enabled == null) - continue; + if (enabled == null) continue; if (enabled) { project.enableFilter(type); @@ -174,8 +172,7 @@ public ResponseEntity importConfigurations( @PathVariable String projectname, @RequestBody ProjectImportDTO importDTO) { Project project = projectService.getProject(projectname); - if (project == null) - return ResponseEntity.notFound().build(); + if (project == null) return ResponseEntity.notFound().build(); for (ImportConfigurationDTO conf : importDTO.configurations()) { Configuration c = new Configuration(conf.filepath()); @@ -212,8 +209,8 @@ public ResponseEntity updateAdapterFromFile( @PathVariable String projectName, @RequestBody AdapterUpdateDTO dto) { Path configPath = Paths.get(dto.configurationPath()); - boolean updated = fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), - dto.adapterXml()); + boolean updated = + fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), dto.adapterXml()); if (!updated) { return ResponseEntity.notFound().build(); From 0be8c3021b442dc0732e6a4d4cfbd46fc09e7650 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Tue, 13 Jan 2026 17:01:38 +0100 Subject: [PATCH 14/16] Added tests --- .../flow/filetree/FileTreeServiceTest.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java index 730eb54..b09edf5 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -249,4 +249,146 @@ void getProjectsRootThrowsIfRootIsAFile() throws IOException { // Cleanup Files.deleteIfExists(tempFile); } + + @Test + public void getShallowDirectoryTreeReturnsTreeForValidDirectory() throws IOException { + + // Add one more file in ProjectA to test multiple children + Path additionalFile = tempRoot.resolve("ProjectA/readme.txt"); + Files.writeString(additionalFile, "hello"); + + FileTreeNode node = fileTreeService.getShallowDirectoryTree("ProjectA", "."); + + assertNotNull(node); + assertEquals("ProjectA", node.getName()); + assertEquals(NodeType.DIRECTORY, node.getType()); + assertNotNull(node.getChildren()); + + // We expect two children now: config1.xml and readme.txt + assertEquals(2, node.getChildren().size()); + + // Verify children names + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("config1.xml"))); + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("readme.txt"))); + } + + @Test + void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() { + SecurityException ex = assertThrows( + SecurityException.class, () -> fileTreeService.getShallowDirectoryTree("ProjectA", "../ProjectB")); + + assertTrue(ex.getMessage().contains("Invalid path")); + } + + @Test + void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExist() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getShallowDirectoryTree("ProjectA", "nonexistent")); + + assertTrue(ex.getMessage().contains("Directory does not exist")); + } + + @Test + public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory() throws IOException { + // Move the existing config1.xml into the expected configurations folder + Path configsDir = tempRoot.resolve("ProjectA/src/main/configurations"); + Files.createDirectories(configsDir); + Files.move( + tempRoot.resolve("ProjectA/config1.xml"), + configsDir.resolve("config1.xml"), + StandardCopyOption.REPLACE_EXISTING); + + Files.writeString(configsDir.resolve("readme.txt"), "hello"); + + FileTreeNode node = fileTreeService.getShallowConfigurationsDirectoryTree("ProjectA"); + + assertNotNull(node); + assertEquals("configurations", node.getName().toLowerCase()); + assertEquals(NodeType.DIRECTORY, node.getType()); + assertNotNull(node.getChildren()); + assertEquals(2, node.getChildren().size()); + + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("config1.xml"))); + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("readme.txt"))); + } + + @Test + public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() { + // No src/main/configurations created for ProjectB + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getShallowConfigurationsDirectoryTree("ProjectB")); + + assertTrue(ex.getMessage().contains("Configurations directory does not exist")); + } + + @Test + public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() { + // Project does not exist + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getShallowConfigurationsDirectoryTree("NonExistentProject")); + + assertTrue(ex.getMessage().contains("Configurations directory does not exist")); + } + + @Test + public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() throws IOException { + // Reuse the existing setup: create the configurations folder + Path configsDir = tempRoot.resolve("ProjectA/src/main/configurations"); + Files.createDirectories(configsDir); + + // Move existing config1.xml into this folder + Files.move( + tempRoot.resolve("ProjectA/config1.xml"), + configsDir.resolve("config1.xml"), + StandardCopyOption.REPLACE_EXISTING); + + // Add an extra file and subdirectory to test recursion + Files.writeString(configsDir.resolve("readme.txt"), "hello"); + Path subDir = configsDir.resolve("subconfigs"); + Files.createDirectory(subDir); + Files.writeString(subDir.resolve("nested.xml"), ""); + + FileTreeNode node = fileTreeService.getConfigurationsDirectoryTree("ProjectA"); + + assertNotNull(node); + assertEquals("configurations", node.getName().toLowerCase()); + assertEquals(NodeType.DIRECTORY, node.getType()); + assertNotNull(node.getChildren()); + assertEquals(3, node.getChildren().size()); // config1.xml, readme.txt, subconfigs + + // Check for files + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("config1.xml"))); + assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("readme.txt"))); + + // Check for subdirectory + FileTreeNode subConfigNode = node.getChildren().stream() + .filter(c -> c.getName().equals("subconfigs")) + .findFirst() + .orElseThrow(); + assertEquals(NodeType.DIRECTORY, subConfigNode.getType()); + assertEquals(1, subConfigNode.getChildren().size()); + assertEquals("nested.xml", subConfigNode.getChildren().get(0).getName()); + } + + @Test + public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() { + // The "src/main/configurations" folder does NOT exist yet + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> fileTreeService.getConfigurationsDirectoryTree("ProjectA")); + + assertTrue(ex.getMessage().contains("Configurations directory does not exist")); + } + + @Test + public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() { + // Project folder itself does not exist + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getConfigurationsDirectoryTree("NonExistingProject")); + + assertTrue(ex.getMessage().contains("Configurations directory does not exist")); + } } From 1828c9c541e9962e207e9c362907e3ad7be960c1 Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Wed, 14 Jan 2026 10:03:32 +0100 Subject: [PATCH 15/16] Added test --- .../frankframework/flow/filetree/FileTreeServiceTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java index b09edf5..706024e 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -196,6 +196,13 @@ void getProjectTreeBuildsTreeCorrectly() throws IOException { assertEquals("config1.xml", tree.getChildren().get(0).getName()); } + @Test + void getProjectTreeThrowsIfProjectDoesNotExist() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> fileTreeService.getProjectTree("NonExistentProject")); + assertTrue(exception.getMessage().contains("Project does not exist: NonExistentProject")); + } + @Test void updateAdapterFromFileReturnsFalseIfInvalidXml() throws IOException, AdapterNotFoundException, ConfigurationNotFoundException { From a750f12d283d545166f91fdabbe43b51cd82213c Mon Sep 17 00:00:00 2001 From: Daan0709 Date: Wed, 14 Jan 2026 14:11:55 +0100 Subject: [PATCH 16/16] Removed auto load of dummy projects, added duplication protection for projects --- .../projectlanding/new-project-modal.tsx | 38 +++++++- .../routes/projectlanding/project-landing.tsx | 86 +++++-------------- .../app/routes/projectlanding/project-row.tsx | 3 + .../exception/GlobalExceptionHandler.java | 8 ++ .../frankframework/flow/project/Project.java | 14 +++ .../ProjectAlreadyExistsException.java | 7 ++ .../flow/project/ProjectController.java | 3 +- .../flow/project/ProjectService.java | 55 ++---------- .../project/testproject/Configuration1.xml | 84 ------------------ .../project/testproject/Configuration2.xml | 34 -------- .../project/testproject/Configuration3.xml | 13 --- .../project/testproject_2/Configuration3.xml | 13 --- 12 files changed, 93 insertions(+), 265 deletions(-) create mode 100644 src/main/java/org/frankframework/flow/project/ProjectAlreadyExistsException.java delete mode 100644 src/main/resources/project/testproject/Configuration1.xml delete mode 100644 src/main/resources/project/testproject/Configuration2.xml delete mode 100644 src/main/resources/project/testproject/Configuration3.xml delete mode 100644 src/main/resources/project/testproject_2/Configuration3.xml diff --git a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx index 8068161..438d085 100644 --- a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx @@ -1,19 +1,47 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' interface NewProjectModalProperties { isOpen: boolean onClose: () => void - onCreate: (name: string) => void + onCreate: (name: string, rootPath: string) => void } export default function NewProjectModal({ isOpen, onClose, onCreate }: Readonly) { const [name, setName] = useState('') + const [rootPath, setRootPath] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isOpen) return + + const fetchData = async () => { + setLoading(true) + setError(null) + + try { + const rootResponse = await fetch('/api/projects/root') + if (!rootResponse.ok) { + throw new Error(`Root HTTP error! Status: ${rootResponse.status}`) + } + + const rootData = await rootResponse.json() + setRootPath(rootData.rootPath) + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to fetch project data') + } finally { + setLoading(false) + } + } + + fetchData() + }, [isOpen]) if (!isOpen) return null const handleCreate = async () => { if (!name.trim()) return - onCreate(name) + onCreate(name, rootPath) onClose() } @@ -21,9 +49,11 @@ export default function NewProjectModal({ isOpen, onClose, onCreate }: Readonly<

Add Project

-

Add a new project

+

Add a new project in {rootPath}

+ {loading &&

Loading rootfolder...

} + {error &&

{error}

} // key = filter name (e.g. "HTTP"), value = true/false } -interface DirectoryFile extends File { - webkitRelativePath: string -} - export default function ProjectLanding() { const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) @@ -27,10 +25,10 @@ export default function ProjectLanding() { const [search, setSearch] = useState('') const [showNewProjectModal, setShowNewProjectModal] = useState(false) const [showLoadProjectModal, setShowLoadProjectModal] = useState(false) + const theme = useTheme() const clearProject = useProjectStore((state) => state.clearProject) const location = useLocation() - const fileInputReference = useRef(null) useEffect(() => { const fetchProjects = async () => { @@ -56,52 +54,7 @@ export default function ProjectLanding() { clearProject() }, [location.key, clearProject]) - const handleOpenProject = () => { - fileInputReference.current?.click() - } - - const handleFolderSelection = async (event: React.ChangeEvent) => { - const files = event.target.files - if (!files || files.length === 0) return - - // Detect project root folder (first directory name) - const firstFile = files[0] as DirectoryFile - const projectRoot = firstFile.webkitRelativePath.split('/')[0] - - // 1. Create project in backend - await createProject(projectRoot) - - // 2. Collect XML configuration files from /src/main/configurations - const configs: { filepath: string; xmlContent: string }[] = [] - - for (const file of [...files] as DirectoryFile[]) { - const relative = file.webkitRelativePath - - if (relative.startsWith(`${projectRoot}/src/main/configurations/`) && relative.endsWith('.xml')) { - const content = await file.text() // read file content - configs.push({ - filepath: relative.replace(`${projectRoot}/`, ''), // path relative to project root - xmlContent: content, - }) - } - } - - // Import configurations to the project - await fetch(`/api/projects/${projectRoot}/import-configurations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectName: projectRoot, - configurations: configs, - }), - }) - - // Sync local project list with backend - const updated = await fetch(`/api/projects/${projectRoot}`).then((res) => res.json()) - setProjects((prev) => prev.map((p) => (p.name === updated.name ? updated : p))) - } - - const createProject = async (projectName: string, rootPath?: string) => { + const createProject = async (projectName: string, rootPath: string) => { try { const response = await fetch(`/api/projects`, { method: 'POST', @@ -114,17 +67,27 @@ export default function ProjectLanding() { }), }) if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json() + toast.error(`Error loading project: ${errorData.error}\nDetails: ${errorData.message}`) + console.error('Something went wrong loading the project:', errorData) + } else { + toast.error(`Error loading project. HTTP status: ${response.status}`) + console.error('Error loading project. HTTP status:', response.status) + } + return } // refresh the project list after creation const newProject = await response.json() setProjects((previous) => [...previous, newProject]) - } catch (error_) { - setError(error_ instanceof Error ? error_.message : 'Failed to create project') + } catch (error) { + toast.error(`Network or unexpected error: ${error}`) + console.error('Network or unexpected error:', error) } } - const loadProject = async () => { + const handleOpenProject = async () => { setShowLoadProjectModal(true) } @@ -141,6 +104,7 @@ export default function ProjectLanding() { return (
+
@@ -165,17 +129,7 @@ export default function ProjectLanding() {
setShowNewProjectModal(true)} /> - - console.log('Cloning project')} /> -
{filteredProjects.map((project, index) => ( diff --git a/src/main/frontend/app/routes/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index 7154c66..99a0ad0 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -3,6 +3,7 @@ import { useProjectStore } from '~/stores/project-store' import KebabVerticalIcon from 'icons/solar/Kebab Vertical.svg?react' import useTabStore from '~/stores/tab-store' import type { Project } from '~/routes/projectlanding/project-landing' +import useEditorTabStore from '~/stores/editor-tab-store' interface ProjectRowProperties { project: Project @@ -13,6 +14,7 @@ export default function ProjectRow({ project }: Readonly) const setProject = useProjectStore((state) => state.setProject) const clearTabs = useTabStore((state) => state.clearTabs) + const clearEditorTabs = useEditorTabStore((state) => state.clearTabs) return (
) onClick={() => { setProject(project) clearTabs() + clearEditorTabs() navigate('/configurations') }} > diff --git a/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java b/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java index f70f59f..d5538a8 100644 --- a/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import org.frankframework.flow.configuration.AdapterNotFoundException; import org.frankframework.flow.configuration.ConfigurationNotFoundException; import org.frankframework.flow.project.InvalidXmlContentException; +import org.frankframework.flow.project.ProjectAlreadyExistsException; import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.springframework.http.HttpStatus; @@ -20,6 +21,13 @@ public ResponseEntity handleProjectNotFound(ProjectNotFoundExc .body(new ErrorResponseDTO("ProjectNotFound", ex.getMessage())); } + @ExceptionHandler(ProjectAlreadyExistsException.class) + public ResponseEntity handleProjectNotFound(ProjectAlreadyExistsException ex) { + ex.printStackTrace(); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponseDTO("ProjectAlreadyExists", ex.getMessage())); + } + @ExceptionHandler(ConfigurationNotFoundException.class) public ResponseEntity handleConfigNotFound(ConfigurationNotFoundException ex) { ex.printStackTrace(); diff --git a/src/main/java/org/frankframework/flow/project/Project.java b/src/main/java/org/frankframework/flow/project/Project.java index 370d8f6..af2acc2 100644 --- a/src/main/java/org/frankframework/flow/project/Project.java +++ b/src/main/java/org/frankframework/flow/project/Project.java @@ -3,6 +3,7 @@ import java.io.ByteArrayInputStream; import java.io.StringWriter; import java.util.ArrayList; +import java.util.Objects; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; @@ -65,6 +66,19 @@ public void clearConfigurations() { configurations.clear(); } + @Override + public boolean equals(Object o) { + if (this == o) return true; // same reference + if (o == null || getClass() != o.getClass()) return false; // different class + Project project = (Project) o; + return Objects.equals(name, project.name) && Objects.equals(rootPath, project.rootPath); + } + + @Override + public int hashCode() { + return Objects.hash(name, rootPath); + } + public boolean updateAdapter(String configurationName, String adapterName, String newAdapterXml) { for (Configuration config : configurations) { if (!config.getFilepath().equals(configurationName)) continue; diff --git a/src/main/java/org/frankframework/flow/project/ProjectAlreadyExistsException.java b/src/main/java/org/frankframework/flow/project/ProjectAlreadyExistsException.java new file mode 100644 index 0000000..720797c --- /dev/null +++ b/src/main/java/org/frankframework/flow/project/ProjectAlreadyExistsException.java @@ -0,0 +1,7 @@ +package org.frankframework.flow.project; + +public class ProjectAlreadyExistsException extends RuntimeException { + public ProjectAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 523aad7..9f748eb 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -220,7 +220,8 @@ public ResponseEntity updateAdapterFromFile( } @PostMapping - public ResponseEntity createProject(@RequestBody ProjectCreateDTO projectCreateDTO) { + public ResponseEntity createProject(@RequestBody ProjectCreateDTO projectCreateDTO) + throws ProjectAlreadyExistsException { Project project = projectService.createProject(projectCreateDTO.name(), projectCreateDTO.rootPath()); ProjectDTO dto = ProjectDTO.from(project); diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index 1819d0e..da93a1c 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -1,7 +1,6 @@ package org.frankframework.flow.project; import java.io.ByteArrayInputStream; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; @@ -17,7 +16,6 @@ import org.frankframework.flow.utility.XmlSecurityUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.stereotype.Service; import org.w3c.dom.Document; @@ -30,7 +28,6 @@ public class ProjectService { @Getter private final ArrayList projects = new ArrayList<>(); - private static final String BASE_PATH = "classpath:project/"; private static final int MIN_PARTS_LENGTH = 2; private final ResourcePatternResolver resolver; private final Path projectsRoot; @@ -39,11 +36,14 @@ public class ProjectService { public ProjectService(ResourcePatternResolver resolver, @Value("${app.project.root}") String rootPath) { this.resolver = resolver; this.projectsRoot = Paths.get(rootPath).toAbsolutePath().normalize(); - initiateProjects(); } - public Project createProject(String name, String rootPath) { + public Project createProject(String name, String rootPath) throws ProjectAlreadyExistsException { Project project = new Project(name, rootPath); + if (projects.contains(project)) { + throw new ProjectAlreadyExistsException( + "Project with name '" + name + "' and rootPath '" + rootPath + "' already exists."); + } projects.add(project); return project; } @@ -176,49 +176,4 @@ public Project addConfiguration(String projectName, String configurationName) { project.addConfiguration(configuration); return project; } - - /** - * Dynamically scan all project folders under /resources/project/ - * Each subdirectory = a project - * Each .xml file = a configuration - */ - private void initiateProjects() { - try { - // Find all XML files recursively under /project/ - Resource[] xmlResources = resolver.getResources(BASE_PATH + "**/*.xml"); - - for (Resource resource : xmlResources) { - String path = resource.getURI().toString(); - - // Example path: file:/.../resources/project/testproject/Configuration1.xml - // Extract the project name between "project/" and the next "/" - String[] parts = path.split("/project/"); - if (parts.length < MIN_PARTS_LENGTH) continue; - - String relativePath = parts[1]; // e.g. "testproject/Configuration1.xml" - String projectName = relativePath.substring(0, relativePath.indexOf("/")); - - // Get or create the Project object - Project project; - try { - project = getProject(projectName); - } catch (ProjectNotFoundException e) { - project = createProject(projectName, projectsRoot.toString()); - } - - // Load XML content - String filename = resource.getFilename(); - String xmlContent = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - - // Create Configuration and add to Project - Configuration configuration = new Configuration(filename); - configuration.setXmlContent(xmlContent); - project.addConfiguration(configuration); - } - - } catch (IOException e) { - System.err.println("Error initializing projects: " + e.getMessage()); - e.printStackTrace(); - } - } } diff --git a/src/main/resources/project/testproject/Configuration1.xml b/src/main/resources/project/testproject/Configuration1.xml deleted file mode 100644 index 0f9b9cd..0000000 --- a/src/main/resources/project/testproject/Configuration1.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/project/testproject/Configuration2.xml b/src/main/resources/project/testproject/Configuration2.xml deleted file mode 100644 index 11e1ee5..0000000 --- a/src/main/resources/project/testproject/Configuration2.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/project/testproject/Configuration3.xml b/src/main/resources/project/testproject/Configuration3.xml deleted file mode 100644 index 75bc566..0000000 --- a/src/main/resources/project/testproject/Configuration3.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/main/resources/project/testproject_2/Configuration3.xml b/src/main/resources/project/testproject_2/Configuration3.xml deleted file mode 100644 index 75bc566..0000000 --- a/src/main/resources/project/testproject_2/Configuration3.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - -