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 773ea57e..153ed5cd 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 @@ -1,5 +1,6 @@ import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' -import { fetchProjectTree } from '~/services/project-service' +import { fetchDirectoryByPath, fetchProjectRootTree } from '~/services/project-service' +import { sortChildren } from './tree-utilities' export interface FileNode { name: string @@ -17,25 +18,19 @@ 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.loadRoot() } - /** - * Public method to initialize data loading. - * Call this from your React component's useEffect. - */ - public async loadData(): Promise { - await this.fetchAndBuildTree() - } - - /** Fetch file tree from backend and build the provider's data */ - private async fetchAndBuildTree() { + /** Fetch root directory from backend and build the provider's data */ + private async loadRoot() { try { if (!this.projectName) return - const tree = await fetchProjectTree(this.projectName) + const tree = await fetchProjectRootTree(this.projectName) if (!tree) { console.warn('[EditorFilesDataProvider] Received empty tree from API') @@ -43,7 +38,30 @@ export default class EditorFilesDataProvider implements TreeDataProvider { return } - this.buildTreeFromFileTree(tree) + this.data['root'] = { + index: 'root', + data: { name: tree.name, path: tree.path }, + isFolder: true, + children: [], + } + + // Sort directories first, then files, both alphabetically + const sortedChildren = sortChildren(tree.children) + + 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(tree.path) this.notifyListeners(['root']) } catch (error) { console.error('[EditorFilesDataProvider] Unexpected error loading tree:', error) @@ -52,38 +70,43 @@ export default class EditorFilesDataProvider implements TreeDataProvider { } } - /** 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' : node.path || `${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 + + try { + const directory = await fetchDirectoryByPath(this.projectName, item.data.path) + if (!directory) { + console.warn('[EditorFilesDataProvider] Received empty directory from API') + this.data = {} + return } - if (node.type === 'DIRECTORY' && node.children) { - for (const child of node.children) { - const childIndex = traverse(child, index) - newData[index].children!.push(childIndex) + const sortedChildren = sortChildren(directory.children) + + 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 - if (rootNode) { - traverse(rootNode, null) + this.loadedDirectories.add(item.data.path) + this.notifyListeners([itemId]) + } catch (error) { + console.error('Failed to load directory', error) } - - this.data = newData } 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 8bac5713..9766154f 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 @@ -50,7 +50,6 @@ export default function EditorFileStructure() { const initProvider = async () => { const provider = new EditorFilesDataProvider(project.name) - await provider.loadData() if (isMounted) { setDataProvider(provider) @@ -114,23 +113,21 @@ 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) }, [dataProvider, openFileTab], ) - const handleItemClick = (items: TreeItemIndex[], _treeId: string): void => { - void handleItemClickAsync(items) - } - useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -184,12 +181,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 = ({ @@ -250,7 +254,7 @@ export default function EditorFileStructure() { viewState={{}} getItemTitle={getItemTitle} dataProvider={dataProvider} - onSelectItems={handleItemClick} + onSelectItems={handleItemClickAsync} canSearch={false} renderItemArrow={renderItemArrow} renderItemTitle={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 60eadc6c..00000000 --- a/src/main/frontend/app/components/file-structure/files-data-provider.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' -import type { FileTreeNode } from './editor-data-provider' -import { getAdaptersFromConfiguration } from '~/routes/studio/xml-to-json-parser' - -export default class FilesDataProvider implements TreeDataProvider { - private data: Record = {} - private readonly treeChangeListeners: ((changedItemIds: TreeItemIndex[]) => void)[] = [] - private readonly 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 = {} - await this.traverse(fileTree, null, newData) - - this.data = newData - this.notifyListeners(['root']) - } - - private async traverse( - node: FileTreeNode, - parentIndex: TreeItemIndex | null, - newData: Record, - ): Promise { - const index = parentIndex === null ? 'root' : `${parentIndex}/${node.name}` - - if (parentIndex === null) { - newData[index] = this.createBaseItem(index, 'Configurations', true) - } - - if (this.shouldSkipNode(node, parentIndex)) { - return null - } - - if (node.type === 'DIRECTORY') { - return this.processDirectoryNode(node, index, newData) - } - - if (node.type === 'FILE') { - await this.processFileNode(node, index, newData) - return index - } - - return null - } - - /** Helper to determine if a node should be ignored */ - private shouldSkipNode(node: FileTreeNode, parentIndex: TreeItemIndex | null): boolean { - return parentIndex !== null && node.type === 'FILE' && !node.name.endsWith('.xml') - } - - /** Helper to create a standard TreeItem object */ - private createBaseItem(index: string, data: string | object, isFolder: boolean): TreeItem { - return { - index, - data, - children: [], - isFolder, - } - } - - private async processFileNode( - node: FileTreeNode, - index: string, - newData: Record, - ): Promise { - newData[index] = this.createBaseItem(index, node.name.replace(/\.xml$/, ''), true) - - try { - const adapters = await getAdaptersFromConfiguration(this.projectName, node.path) - - for (const adapter of adapters) { - const adapterIndex = `${index}/${adapter.name}` - - newData[adapterIndex] = this.createBaseItem( - adapterIndex, - { adapterName: adapter.name, configPath: node.path, listenerName: adapter.listenerType }, - false, - ) - - newData[index].children!.push(adapterIndex) - } - } catch (error) { - console.error(`Failed to load adapters for ${node.path}:`, error) - } - } - - private async processDirectoryNode( - node: FileTreeNode, - index: string, - newData: Record, - ): Promise { - if (!newData[index]) { - newData[index] = this.createBaseItem(index, node.name, true) - } - - if (node.children) { - for (const child of node.children) { - const childIndex = await this.traverse(child, index, newData) - if (childIndex) { - newData[index].children!.push(childIndex) - } - } - } - - if (index !== 'root' && newData[index].children!.length === 0) { - delete newData[index] - return null - } - - return index - } - - 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 82% 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 e3259c58..489f8257 100644 --- a/src/main/frontend/app/components/file-structure/file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx @@ -17,41 +17,28 @@ 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 type { FileTreeNode } from './editor-data-provider' +import type { FileNode, FileTreeNode } from './editor-data-provider' import { useProjectTree } from '~/hooks/use-project-tree' const TREE_ID = 'studio-files-tree' -function getItemTitle(item: TreeItem): string { +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 | undefined | null): FileTreeNode | null { - if (!node || !node.path) return 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((state) => state.project) const [searchTerm, setSearchTerm] = useState('') const [matchingItemIds, setMatchingItemIds] = useState([]) @@ -73,16 +60,8 @@ export default function FileStructure() { const initProvider = async () => { setProviderLoading(true) - const configurationsRoot = findConfigurationsDir(treeData) - - if (!configurationsRoot) { - setDataProvider(null) - setProviderLoading(false) - return - } const provider = new FilesDataProvider(project.name) - await provider.updateData(configurationsRoot) setDataProvider(provider) setProviderLoading(false) } @@ -104,8 +83,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) @@ -121,6 +100,27 @@ export default function FileStructure() { findMatchingItems() }, [searchTerm, dataProvider]) + const handleItemClick = (items: TreeItemIndex[], _treeId: string): void => { + void handleItemClickAsync(items) + } + + const loadFolderContents = useCallback( + async (item: TreeItem) => { + if (!item.isFolder) return + + const path = item.data.path + + if (path.endsWith('.xml') && dataProvider) { + // XML configs can contain adapters + if (dataProvider) await dataProvider.loadAdapters(item.index) + } else { + // Normal directory + if (dataProvider) await dataProvider.loadDirectory(item.index) + } + }, + [dataProvider], + ) + const openNewTab = useCallback( (adapterName: string, configPath: string) => { if (!getTab(adapterName)) { @@ -130,6 +130,7 @@ export default function FileStructure() { flowJson: {}, }) } + setActiveTab(adapterName) }, [getTab, setTabData, setActiveTab], @@ -143,21 +144,23 @@ export default function FileStructure() { if (typeof itemId !== 'string') return const item = await dataProvider.getTreeItem(itemId) - if (!item || item.isFolder) return + if (!item) 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 } openNewTab(adapterName, configPath) } }, - [dataProvider, openNewTab], + [dataProvider, loadFolderContents, openNewTab], ) - const handleItemClick = (items: TreeItemIndex[], _treeId: string): void => { - void handleItemClickAsync(items) - } - useEffect(() => { const handleKeyDown = async (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -205,14 +208,19 @@ export default function FileStructure() { tree.current.expandAll() }, [searchTerm]) - const renderItemArrow = ({ item, context }: { item: TreeItem; context: TreeItemRenderContext }) => { + 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 ( - + ) } @@ -222,7 +230,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 00000000..c053b143 --- /dev/null +++ b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts @@ -0,0 +1,152 @@ +import type { Disposable, TreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree' +import { getAdapterListenerType, getAdapterNamesFromConfiguration } from '~/routes/studio/xml-to-json-parser' +import { sortChildren } from './tree-utilities' +import { fetchDirectoryByPath, fetchProjectTree } from '~/services/project-service' + +export default class FilesDataProvider 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.loadRoot() + } + + // Load root directory + private async loadRoot() { + const tree = await fetchProjectTree(this.projectName) + + if (!tree) { + console.warn('[EditorFilesDataProvider] Received empty tree from API') + this.data = {} + return + } + + this.data['root'] = { + index: 'root', + data: 'Configurations', + children: [], + isFolder: true, + } + + const sortedChildren = sortChildren(tree.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(tree.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 directory = await fetchDirectoryByPath(this.projectName, item.data.path) + if (!directory) { + console.warn('[StudioFilesDataProvider] Received empty directory from API') + this.data = {} + return + } + + const sortedChildren = sortChildren(directory.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 a2660d42..79b4f826 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/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index e9b49f15..7154c66d 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 33dd2c0f..9f594570 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/services/project-service.ts b/src/main/frontend/app/services/project-service.ts index 67d020e1..2f440c00 100644 --- a/src/main/frontend/app/services/project-service.ts +++ b/src/main/frontend/app/services/project-service.ts @@ -44,9 +44,23 @@ export async function fetchProjectRoot(signal?: AbortSignal): Promise<{ rootPath } export async function fetchProjectTree(projectName: string, signal?: AbortSignal): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree/configurations`, { signal }) +} + +export async function fetchProjectRootTree(projectName: string, signal?: AbortSignal): Promise { return apiFetch(`/projects/${encodeURIComponent(projectName)}/tree`, { signal }) } +export async function fetchDirectoryByPath( + projectName: string, + path: string, + signal?: AbortSignal, +): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}?path=${encodeURIComponent(path)}`, { + signal, + }) +} + export async function toggleProjectFilter(projectName: string, filter: string, enable: boolean): Promise { const action = enable ? 'enable' : 'disable' await apiFetch(`/projects/${encodeURIComponent(projectName)}/filters/${encodeURIComponent(filter)}/${action}`, { diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index a52034ea..4eaa78ae 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 92ebab67..23974bdd 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -26,6 +26,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() @@ -67,6 +68,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 { @@ -77,6 +90,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 2964e362..d48ecb6b 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -186,6 +186,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 { @@ -243,4 +250,146 @@ void getProjectsRootThrowsIfRootIsAFile() throws IOException { 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")); + } }