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 e2ecba5..0000000 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ /dev/null @@ -1,129 +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']) - } - - 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 80% 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 3fb71bf..d4bf379 100644 --- a/src/main/frontend/app/components/file-structure/file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/studio-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' @@ -16,10 +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 } from './tree-utilities' -import type { FileTreeNode } from './editor-data-provider' +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('') @@ -72,26 +59,25 @@ 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 { - const response = await fetch(`/api/projects/${project.name}/tree`) - const tree: FileTreeNode = await response.json() - const configurationsRoot = findConfigurationsDir(tree) - if (!configurationsRoot) return + setIsTreeLoading(true) - await dataProviderReference.current.updateData(configurationsRoot) + try { + // 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) + console.error('Failed to load file tree', error) } finally { setIsTreeLoading(false) + setIsTreeLoading(false) } } - loadFileTree() + void loadFileTree() }, [project]) useEffect(() => { @@ -108,8 +94,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) @@ -133,23 +119,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, { @@ -230,16 +231,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 ( - + ) } @@ -249,7 +253,7 @@ export default function FileStructure() { context, }: { title: string - item: TreeItem + item: TreeItem context: TreeItemRenderContext }) => { const searchLower = searchTerm.toLowerCase() @@ -305,6 +309,9 @@ export default function FileStructure() { if (!project) { return

Loading project...

} + if (!project) { + return

Loading project...

+ } if (isTreeLoading) { return

Loading configurations...

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 a2660d4..79b4f82 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,18 @@ export function getListenerIcon(listenerType: string | null) { return listenerIconMap[listenerType] ?? CodeIcon } + +function getSortRank(child: FileTreeNode) { + if (child.type === 'DIRECTORY') return 0 + if (child.type === 'FILE' && child.name.endsWith('.xml')) return 1 + return 2 +} + +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 e9b49f1..7154c66 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -1,6 +1,5 @@ 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' @@ -13,7 +12,6 @@ export default function ProjectRow({ project }: Readonly) const navigate = useNavigate() const setProject = useProjectStore((state) => state.setProject) - const clearConfigs = useTreeStore((state) => state.clearConfigs) const clearTabs = useTabStore((state) => state.clearTabs) return ( @@ -21,7 +19,6 @@ 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() 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 faa5cec..0000000 --- a/src/main/frontend/app/stores/tree-store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ConfigWithAdapters } from '~/components/file-structure/file-structure' -import { create } from 'zustand' - -interface TreestoreState { - configs: ConfigWithAdapters[] - isLoading: boolean - setConfigs: (configs: ConfigWithAdapters[]) => void - setIsLoading: (loading: boolean) => void - clearConfigs: () => void -} - -export const useTreeStore = create((set) => ({ - configs: [], - isLoading: true, - setConfigs: (configs) => set({ configs }), - setIsLoading: (isLoading) => set({ isLoading }), - clearConfigs: () => set({ configs: [], isLoading: true }), -})) diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 2c7577f..9d29a1d 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -94,7 +94,47 @@ 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 (!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); + } + + 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( @@ -140,7 +180,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()); @@ -168,4 +208,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..523aad7 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,13 @@ 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) { diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java index 730eb54..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 { @@ -249,4 +256,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")); + } }