From 4cc0a795870852c75c4f3e444cf16be7902413d7 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 20 May 2026 11:06:50 +0200 Subject: [PATCH 1/4] Add reveal functionality for active tab in file explorer and enhance shortcuts --- .../file-structure/editor-file-structure.tsx | 62 +++++- .../file-structure/studio-file-structure.tsx | 201 +++++++++++++++++- .../file-structure/tree-utilities.ts | 26 ++- src/main/frontend/app/hooks/use-shortcut.ts | 2 +- .../frontend/app/stores/shortcut-store.ts | 2 + src/main/frontend/icons/solar/List Down.svg | 4 + 6 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 src/main/frontend/icons/solar/List Down.svg 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 15beef18..5ba101f3 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,8 +1,9 @@ -import React, { type JSX, useCallback, useEffect, useRef, useState } from 'react' +import React, { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react' import Search from '~/components/search/search' import LoadingSpinner from '~/components/loading-spinner' import FolderIcon from '../../../icons/solar/Folder.svg?react' import FolderOpenIcon from '../../../icons/solar/Folder Open.svg?react' +import ListDown from '../../../icons/solar/List Down.svg?react' import '/styles/editor-files.css' import AltArrowRightIcon from '../../../icons/solar/Alt Arrow Right.svg?react' import AltArrowDownIcon from '../../../icons/solar/Alt Arrow Down.svg?react' @@ -11,6 +12,7 @@ import CodeFileIcon from '../../../icons/solar/Code File.svg?react' import TrashBinIcon from '../../../icons/solar/Trash Bin.svg?react' import Pen from '../../../icons/solar/Pen.svg?react' import { useShortcut } from '~/hooks/use-shortcut' +import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities' import type { ContextMenuState } from './use-file-tree-context-menu' import { @@ -51,9 +53,11 @@ export default function EditorFileStructure() { const getTab = useEditorTabStore((state) => state.getTab) const removeTab = useEditorTabStore((state) => state.removeTab) const removeTabAndSelectFallback = useEditorTabStore((state) => state.removeTabAndSelectFallback) + const activeTabFilePath = useEditorTabStore((state) => state.activeTabFilePath) const [dataProvider, setDataProvider] = useState(null) const [selectedItemId, setSelectedItemId] = useState(null) + const [rootPath, setRootPath] = useState(null) const expandedItemsRef = useRef(editorExpandedItems) @@ -61,6 +65,26 @@ export default function EditorFileStructure() { expandedItemsRef.current = editorExpandedItems }, [editorExpandedItems]) + useEffect(() => { + if (!dataProvider) { + setRootPath(null) + return + } + void dataProvider.getTreeItem('root').then((root) => { + if (root) setRootPath((root.data as FileNode).path) + }) + }, [dataProvider]) + + const activeTabItemId = useMemo( + () => (rootPath && activeTabFilePath ? toTreeItemId(activeTabFilePath, rootPath) : null), + [rootPath, activeTabFilePath], + ) + + const isActiveItemVisible = useMemo( + () => isVisibleInTree(activeTabItemId, editorExpandedItems), + [activeTabItemId, editorExpandedItems], + ) + const onAfterRename = useCallback( (oldPath: string, newName: string) => { const tab = getTab(oldPath) @@ -126,12 +150,31 @@ export default function EditorFileStructure() { [buildContextForItem], ) + const revealActiveFile = useCallback(async () => { + if (!dataProvider || !activeTabFilePath || !rootPath || !tree.current) return + + const itemId = toTreeItemId(activeTabFilePath, rootPath) + + for (const ancestorId of getAncestorIds(itemId)) { + await dataProvider.loadDirectory(ancestorId) + tree.current.expandItem(ancestorId) + } + + selectAndReveal(tree.current, itemId) + }, [dataProvider, activeTabFilePath, rootPath]) + useShortcut({ 'explorer.new-file': () => triggerExplorerAction(editorContextMenu.handleNewFile, false), 'explorer.new-folder': () => triggerExplorerAction(editorContextMenu.handleNewFolder, false), - 'explorer.rename': () => triggerExplorerAction(editorContextMenu.handleRename, true), - 'explorer.delete': () => triggerExplorerAction(editorContextMenu.handleDelete, true), - 'explorer.delete-mac': () => triggerExplorerAction(editorContextMenu.handleDelete, true), + 'explorer.rename': () => { + if (!selectedItemId) return false + triggerExplorerAction(editorContextMenu.handleRename, true) + }, + 'explorer.delete': () => { + if (!selectedItemId) return false + triggerExplorerAction(editorContextMenu.handleDelete, true) + }, + 'explorer.reveal': () => void revealActiveFile(), }) useEffect(() => { @@ -421,6 +464,14 @@ export default function EditorFileStructure() {
Explorer
+ + + +
+
+
+ setSearchTerm(event.target.value)} /> +
{ @@ -388,6 +572,7 @@ export default function StudioFileStructure() { }} onCollapseItem={(item) => { removeStudioExpandedItem(String(item.index)) + setSelectedItemId((prev) => (prev && String(prev).startsWith(`${String(item.index)}/`) ? null : prev)) }} getItemTitle={getItemTitle} dataProvider={dataProvider} 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 03346fbb..fa58e30e 100644 --- a/src/main/frontend/app/components/file-structure/tree-utilities.ts +++ b/src/main/frontend/app/components/file-structure/tree-utilities.ts @@ -4,6 +4,7 @@ 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 '~/types/filesystem.types' +import type { TreeRef } from 'react-complex-tree' export function getListenerIcon(listenerType: string | null) { if (!listenerType) return CodeIcon @@ -24,8 +25,31 @@ function getSortRank(child: FileTreeNode) { return 2 } +export function getAncestorIds(itemId: string): string[] { + const parts = itemId.split('/') + return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/')) +} + +export function toTreeItemId(absolutePath: string, rootPath: string): string { + const relativePath = absolutePath.slice(rootPath.length).replace(/^[/\\]/, '') + return `root/${relativePath.split(/[/\\]/).join('/')}` +} + +export function isVisibleInTree(itemId: string | null, expandedItems: string[]): boolean { + if (!itemId) return false + return getAncestorIds(itemId) + .slice(1) + .every((id) => expandedItems.includes(id)) +} + +export function selectAndReveal(treeRef: TreeRef, itemId: string): void { + setTimeout(() => { + treeRef.selectItems([itemId]) + treeRef.focusItem(itemId) + }, 50) +} + 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 diff --git a/src/main/frontend/app/hooks/use-shortcut.ts b/src/main/frontend/app/hooks/use-shortcut.ts index 322b790a..29615850 100644 --- a/src/main/frontend/app/hooks/use-shortcut.ts +++ b/src/main/frontend/app/hooks/use-shortcut.ts @@ -15,7 +15,7 @@ export function useShortcut(handlers: ShortcutHandlers) { for (const id of handlerIds) { setHandler(id, () => { - handlersRef.current[id]?.() + return handlersRef.current[id]?.() }) } diff --git a/src/main/frontend/app/stores/shortcut-store.ts b/src/main/frontend/app/stores/shortcut-store.ts index f607f839..f6b1a0e2 100644 --- a/src/main/frontend/app/stores/shortcut-store.ts +++ b/src/main/frontend/app/stores/shortcut-store.ts @@ -184,6 +184,7 @@ export const ALL_SHORTCUTS: Omit[] = [ { id: 'studio-explorer.new-adapter', label: 'New Adapter', scope: 'studio', key: 'a' }, { id: 'studio-explorer.new-folder', label: 'New Folder', scope: 'studio', key: 'n', modifiers: { shift: true } }, { id: 'studio-explorer.rename', label: 'Rename Item', scope: 'studio', key: 'r' }, + { id: 'studio-explorer.reveal', label: 'Open File Tree to Active Tab', scope: 'studio', key: 'o' }, { id: 'studio-explorer.delete', label: 'Delete Item', @@ -196,6 +197,7 @@ export const ALL_SHORTCUTS: Omit[] = [ { id: 'explorer.new-file', label: 'New File', scope: 'editor', key: 'n' }, { id: 'explorer.new-folder', label: 'New Folder', scope: 'editor', key: 'n', modifiers: { shift: true } }, { id: 'explorer.rename', label: 'Rename Item', scope: 'editor', key: 'r' }, + { id: 'explorer.reveal', label: 'Open File Tree to Active Tab', scope: 'editor', key: 'o' }, { id: 'explorer.delete', label: 'Delete Item', diff --git a/src/main/frontend/icons/solar/List Down.svg b/src/main/frontend/icons/solar/List Down.svg new file mode 100644 index 00000000..134a23d4 --- /dev/null +++ b/src/main/frontend/icons/solar/List Down.svg @@ -0,0 +1,4 @@ + + + + From 92f8e0b562a8231195f6b594dd21c1f46a72912a Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 20 May 2026 11:14:58 +0200 Subject: [PATCH 2/4] Replace PlusMinusIcon with CodeIcon in file structure component --- .../app/components/file-structure/studio-file-structure.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 65f0e885..1dc6b793 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 @@ -7,7 +7,7 @@ import FolderIcon from '../../../icons/solar/Folder.svg?react' import FolderOpenIcon from '../../../icons/solar/Folder Open.svg?react' import SettingsIcon from '../../../icons/solar/Settings.svg?react' import ListDown from '../../../icons/solar/List Down.svg?react' -import PlusMinusIcon from '../../../icons/solar/Plus, Minus.svg?react' +import CodeIcon from '../../../icons/solar/Code.svg?react' import TrashBinIcon from '../../../icons/solar/Trash Bin.svg?react' import Pen from '../../../icons/solar/Pen.svg?react' import '/styles/editor-files.css' @@ -473,7 +473,7 @@ export default function StudioFileStructure() { keyboardEvent.key === 'Enter' && triggerItemAction(item.index, studioContextMenu.handleNewAdapter) } > - +
)} {!isRoot && ( From 05c1cf643234650c418959e3c8bfbd4fc4b965e9 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 26 May 2026 11:09:54 +0200 Subject: [PATCH 3/4] Add fetchAncestorPath functionality to retrieve ancestor directories in file tree --- .../file-structure/studio-file-structure.tsx | 5 +- .../studio-files-data-provider.ts | 41 +++++++++- .../app/services/file-tree-service.ts | 10 +++ .../flow/file/FileTreeController.java | 8 ++ .../flow/file/FileTreeService.java | 82 ++++++++++++++----- 5 files changed, 123 insertions(+), 23 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 1dc6b793..16676ca7 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 @@ -145,8 +145,9 @@ export default function StudioFileStructure() { const configItemId = itemId.slice(0, itemId.lastIndexOf('/')) + await dataProvider.loadAncestorDirectories(configItemId) + for (const ancestorId of getAncestorIds(configItemId)) { - await dataProvider.loadDirectory(ancestorId) tree.current.expandItem(ancestorId) } @@ -371,7 +372,7 @@ export default function StudioFileStructure() { const isObject = typeof item.data === 'object' - const pathEndsWithXmlExtension = (item.data as Partial).path?.endsWith('.xml') + const pathEndsWithXmlExtension = (item.data as Partial).path?.endsWith('.xml') ?? false const isRoot = typeof item.data === 'string' const isConfigFile = item.isFolder && isObject && item.data !== null && pathEndsWithXmlExtension diff --git a/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts index 01d6142c..626f2854 100644 --- a/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/studio-files-data-provider.ts @@ -1,6 +1,6 @@ import type { TreeItemIndex } from 'react-complex-tree' -import { sortChildren } from './tree-utilities' -import { fetchProjectTree, fetchDirectoryByPath } from '~/services/file-tree-service' +import { sortChildren, getAncestorIds } from './tree-utilities' +import { fetchProjectTree, fetchDirectoryByPath, fetchAncestorPath } from '~/services/file-tree-service' import type { FileTreeNode } from '~/types/filesystem.types' import { BaseFilesDataProvider } from './base-files-data-provider' @@ -129,6 +129,43 @@ export default class FilesDataProvider extends BaseFilesDataProvider 0) this.notifyListeners(changedIds) + } catch (error) { + console.error(`Failed to load ancestor directories for ${path}`, error) + } + } + + private applyAncestorTree(node: FileTreeNode, itemId: TreeItemIndex, changedIds: TreeItemIndex[]) { + const item = this.data[itemId] + if (!item?.isFolder) return + + const childOnPath = node.children?.find((child) => child.children != null) + + if (isFolderData(item.data) && !this.loadedDirectories.has(node.path)) { + item.children = sortChildren(node.children ?? []).map((child) => this.buildChildItem(itemId, child)) + this.loadedDirectories.add(node.path) + changedIds.push(itemId) + } + + if (childOnPath) { + const childItemId = `${itemId}/${childOnPath.name}` + this.applyAncestorTree(childOnPath, childItemId, changedIds) + } + } + private buildChildItem(parentId: TreeItemIndex, child: FileTreeNode): TreeItemIndex { const index = `${parentId}/${child.name}` const isFolder = child.type === 'DIRECTORY' || child.name.endsWith('.xml') diff --git a/src/main/frontend/app/services/file-tree-service.ts b/src/main/frontend/app/services/file-tree-service.ts index 2a331666..6b950b9a 100644 --- a/src/main/frontend/app/services/file-tree-service.ts +++ b/src/main/frontend/app/services/file-tree-service.ts @@ -23,6 +23,16 @@ export async function fetchDirectoryByPath( }) } +export async function fetchAncestorPath( + projectName: string, + path: string, + signal?: AbortSignal, +): Promise { + return apiFetch(`${getTreeUrl(projectName)}/ancestors?path=${encodeURIComponent(path)}`, { + signal, + }) +} + export async function createFolderInProject(projectName: string, path: string): Promise { return apiFetch(`${getBaseUrl(projectName)}/folder`, { method: 'POST', diff --git a/src/main/java/org/frankframework/flow/file/FileTreeController.java b/src/main/java/org/frankframework/flow/file/FileTreeController.java index 4654ad68..422eb0da 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeController.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeController.java @@ -47,6 +47,14 @@ public FileTreeNode getDirectoryContent( return fileTreeService.getShallowDirectoryTree(projectName, path); } + @GetMapping(value = "/tree/ancestors", params = "path") + public FileTreeNode getAncestorPath( + @PathVariable String projectName, + @RequestParam String path + ) throws IOException { + return fileTreeService.getAncestorPath(projectName, path); + } + @PostMapping("/folder") public ResponseEntity createFolder(@PathVariable String projectName, @RequestBody FolderCreateDTO dto) throws IOException, ApiException { FileTreeNode node = fileTreeService.createFolder(projectName, dto.path()); diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index c747861f..3edea9ce 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -66,25 +66,8 @@ public FileTreeNode getProjectTree(String projectName) throws IOException { } public FileTreeNode getShallowDirectoryTree(String projectName, String directoryPath) throws IOException { - try { - ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); - Path projectPath = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()); - Path dirPath = fileSystemStorage.toAbsolutePath(directoryPath).normalize(); - - if (!dirPath.startsWith(projectPath)) { - throw new SecurityException("Invalid path: outside project directory"); - } - - if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { - throw new IllegalArgumentException("Directory does not exist: " + dirPath); - } - - boolean useRelativePaths = !fileSystemStorage.isLocalEnvironment(); - Path relativizeRoot = useRelativePaths ? fileSystemStorage.toAbsolutePath("") : projectPath; - return buildShallowTree(dirPath, relativizeRoot, useRelativePaths); - } catch (ApiException _) { - throw new IllegalArgumentException("Project does not exist: " + projectName); - } + ProjectDirectory projectDirectory = resolveProjectDirectory(projectName, directoryPath); + return buildShallowTree(projectDirectory.dirPath, projectDirectory.relativizeRoot, projectDirectory.useRelativePaths); } public FileTreeNode getShallowConfigurationsDirectoryTree(String projectName) throws IOException { @@ -105,6 +88,11 @@ public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IO } } + public FileTreeNode getAncestorPath(String projectName, String directoryPath) throws IOException { + ProjectDirectory projecetDirectory = resolveProjectDirectory(projectName, directoryPath); + return buildAncestorTree(projecetDirectory.projectPath, projecetDirectory.dirPath, projecetDirectory.relativizeRoot, projecetDirectory.useRelativePaths); + } + public FileTreeNode createFolder(String projectName, String path) throws IOException { fileService.validatePath(path); fileService.validateWithinProject(projectName, path); @@ -201,6 +189,33 @@ private String toNodePath(Path path, Path relativizeRoot, boolean useRelativePat return relativePath.isEmpty() ? "." : relativePath; } + private FileTreeNode buildAncestorTree(Path current, Path target, Path relativizeRoot, boolean useRelativePaths) throws IOException { + FileTreeNode node = buildShallowTree(current, relativizeRoot, useRelativePaths); + if (current.equals(target)) { + return node; + } + + Path nextOnPath = target; + while (!nextOnPath.getParent().equals(current)) { + nextOnPath = nextOnPath.getParent(); + } + + String spineChildName = nextOnPath.getFileName().toString(); + Path spineChildPath = nextOnPath; + + List updatedChildren = new ArrayList<>(node.getChildren().size()); + for (FileTreeNode child : node.getChildren()) { + if (child.getName().equals(spineChildName)) { + updatedChildren.add(buildAncestorTree(spineChildPath, target, relativizeRoot, useRelativePaths)); + } else { + updatedChildren.add(child); + } + } + + node.setChildren(updatedChildren); + return node; + } + private FileTreeNode buildShallowTree(Path path, Path relativizeRoot, boolean useRelativePaths) throws IOException { FileTreeNode node = new FileTreeNode(); node.setName(path.getFileName().toString()); @@ -231,6 +246,35 @@ private FileTreeNode buildShallowTree(Path path, Path relativizeRoot, boolean us return node; } + private ProjectDirectory resolveProjectDirectory(String projectName, String directoryPath) throws IOException { + try { + ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()); + Path dirPath = fileSystemStorage.toAbsolutePath(directoryPath).normalize(); + + if (!dirPath.startsWith(projectPath)) { + throw new SecurityException("Invalid path: outside project directory"); + } + + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { + throw new IllegalArgumentException("Directory does not exist: " + dirPath); + } + + boolean useRelativePaths = !fileSystemStorage.isLocalEnvironment(); + Path relativizeRoot = useRelativePaths ? fileSystemStorage.toAbsolutePath("") : projectPath; + return new ProjectDirectory(projectPath, dirPath, relativizeRoot, useRelativePaths); + } catch (ApiException _) { + throw new IllegalArgumentException("Project does not exist: " + projectName); + } + } + + private record ProjectDirectory( + Path projectPath, + Path dirPath, + Path relativizeRoot, + boolean useRelativePaths + ) {} + private record ConfigurationDirectory( Path directoryPath, Path relativizeRoot, From 0b3b687a771a10864f9f8f1c0e0cccd9929bdc9d Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 26 May 2026 11:26:50 +0200 Subject: [PATCH 4/4] Add unit tests for getAncestorPath method in FileTreeService --- .../flow/file/FileTreeControllerTest.java | 52 +++ .../flow/file/FileTreeServiceTest.java | 315 ++++++++++++++++++ 2 files changed, 367 insertions(+) diff --git a/src/test/java/org/frankframework/flow/file/FileTreeControllerTest.java b/src/test/java/org/frankframework/flow/file/FileTreeControllerTest.java index 3140afd6..a24ec833 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeControllerTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeControllerTest.java @@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -87,6 +88,57 @@ void getDirectoryContentRequiresPathParameter() throws Exception { verify(fileTreeService, never()).getShallowDirectoryTree("MyProject", ""); } + @Test + void getAncestorPathReturnsSparseTreeNode() throws Exception { + FileTreeNode spineChild = new FileTreeNode(); + spineChild.setName("MyConfig"); + spineChild.setPath("configurations/MyConfig"); + spineChild.setType(NodeType.DIRECTORY); + spineChild.setChildren(List.of()); + + FileTreeNode rootNode = new FileTreeNode(); + rootNode.setName("configurations"); + rootNode.setPath("configurations"); + rootNode.setType(NodeType.DIRECTORY); + rootNode.setChildren(List.of(spineChild)); + + when(fileTreeService.getAncestorPath("MyProject", "configurations/MyConfig")).thenReturn(rootNode); + + mockMvc.perform(get("/api/projects/MyProject/tree/ancestors").param("path", "configurations/MyConfig")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("configurations")) + .andExpect(jsonPath("$.type").value("DIRECTORY")) + .andExpect(jsonPath("$.children[0].name").value("MyConfig")) + .andExpect(jsonPath("$.children[0].children").isArray()); + + verify(fileTreeService).getAncestorPath("MyProject", "configurations/MyConfig"); + } + + @Test + void getAncestorPathRequiresPathParameter() throws Exception { + mockMvc.perform(get("/api/projects/MyProject/tree/ancestors")) + .andExpect(status().isBadRequest()); + + verify(fileTreeService, never()).getAncestorPath("MyProject", null); + } + + @Test + void getAncestorPathForwardsExactPathValueToService() throws Exception { + FileTreeNode node = new FileTreeNode(); + node.setName("nested"); + node.setPath("a/b/c/nested"); + node.setType(NodeType.DIRECTORY); + node.setChildren(List.of()); + + when(fileTreeService.getAncestorPath("MyProject", "a/b/c/nested")).thenReturn(node); + + mockMvc.perform(get("/api/projects/MyProject/tree/ancestors").param("path", "a/b/c/nested")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("nested")); + + verify(fileTreeService).getAncestorPath("MyProject", "a/b/c/nested"); + } + @Test void createFolderReturnsCreatedNode() throws Exception { FileTreeNode treeNode = new FileTreeNode(); diff --git a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java index 34c58acd..c4fd832c 100644 --- a/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/file/FileTreeServiceTest.java @@ -424,6 +424,321 @@ void getShallowDirectoryTree_LocalEnvironment_UsesAbsolutePaths() throws IOExcep ); } + @Test + @DisplayName("Should throw IllegalArgumentException when the project is not registered") + void getAncestorPath_unknownProject_throwsIllegalArgument() throws ApiException { + when(configurationProjectService.getProject("Unknown")).thenThrow(new ApiException("err", HttpStatus.NOT_FOUND)); + assertThrows(IllegalArgumentException.class, () -> fileTreeService.getAncestorPath("Unknown", ".")); + } + + @Test + @DisplayName("Should throw SecurityException when the path escapes the project root") + void getAncestorPath_pathTraversal_throwsSecurityException() throws ApiException { + stubToAbsolutePath(); + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + SecurityException ex = assertThrows( + SecurityException.class, + () -> fileTreeService.getAncestorPath(TEST_PROJECT_NAME, "../outside") + ); + assertTrue(ex.getMessage().contains("Invalid path")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when the target directory does not exist on disk") + void getAncestorPath_nonExistentDirectory_throwsIllegalArgument() throws ApiException { + stubToAbsolutePath(); + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getAncestorPath(TEST_PROJECT_NAME, "nonexistent") + ); + assertTrue(ex.getMessage().contains("Directory does not exist")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when the target path points to a file instead of a directory") + void getAncestorPath_pathIsFile_throwsIllegalArgument() throws IOException, ApiException { + stubToAbsolutePath(); + Files.writeString(tempProjectRoot.resolve("aFile.xml"), ""); + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + assertThrows( + IllegalArgumentException.class, + () -> fileTreeService.getAncestorPath(TEST_PROJECT_NAME, "aFile.xml") + ); + } + + @Test + @DisplayName("Should return the shallow project-root tree when the target is the project root itself") + void getAncestorPath_targetIsProjectRoot_returnsShallowRootTree() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Files.writeString(tempProjectRoot.resolve("config.xml"), ""); + Files.createDirectory(tempProjectRoot.resolve("subdir")); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode result = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + + assertNotNull(result); + assertEquals(NodeType.DIRECTORY, result.getType()); + assertEquals(2, result.getChildren().size()); + assertTrue(result.getChildren().stream().anyMatch(c -> c.getName().equals("config.xml"))); + assertTrue(result.getChildren().stream().anyMatch(c -> c.getName().equals("subdir"))); + result.getChildren().forEach(c -> assertNull(c.getChildren())); + } + + @Test + @DisplayName("Should return a node with empty children list when the target directory is empty") + void getAncestorPath_emptyTargetDirectory_spineHasEmptyChildrenList() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path emptyDir = Files.createDirectory(tempProjectRoot.resolve("emptyDir")); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, emptyDir.toAbsolutePath().toString()); + + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("emptyDir")) + .findFirst().orElseThrow(); + assertNotNull(spineChild.getChildren()); + assertTrue(spineChild.getChildren().isEmpty()); + } + + @Test + @DisplayName("Spine child has children populated; all siblings have null children") + void getAncestorPath_singleLevel_spineHasChildrenSiblingsAreShallow() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path target = Files.createDirectory(tempProjectRoot.resolve("target")); + Files.writeString(target.resolve("content.xml"), ""); + Files.createDirectory(tempProjectRoot.resolve("sibling")); + Files.writeString(tempProjectRoot.resolve("root.xml"), ""); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, target.toAbsolutePath().toString()); + + assertEquals(3, root.getChildren().size()); + + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("target")) + .findFirst().orElseThrow(); + assertNotNull(spineChild.getChildren()); + assertEquals(1, spineChild.getChildren().size()); + assertEquals("content.xml", spineChild.getChildren().getFirst().getName()); + + root.getChildren().stream() + .filter(c -> !c.getName().equals("target")) + .forEach(c -> assertNull(c.getChildren())); + } + + @Test + @DisplayName("Two-level path: root has full shallow children; spine expands into target with its shallow children") + void getAncestorPath_twoLevel_correctSpineAtBothLevels() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path level1 = Files.createDirectory(tempProjectRoot.resolve("level1")); + Path level2 = Files.createDirectory(level1.resolve("level2")); + Files.writeString(level2.resolve("target.xml"), ""); + Files.createDirectory(level1.resolve("level1Sibling")); + Files.createDirectory(tempProjectRoot.resolve("rootSibling")); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, level2.toAbsolutePath().toString()); + + FileTreeNode level1Node = root.getChildren().stream() + .filter(c -> c.getName().equals("level1")).findFirst().orElseThrow(); + FileTreeNode rootSiblingNode = root.getChildren().stream() + .filter(c -> c.getName().equals("rootSibling")).findFirst().orElseThrow(); + assertNotNull(level1Node.getChildren()); + assertNull(rootSiblingNode.getChildren()); + + FileTreeNode level2Node = level1Node.getChildren().stream() + .filter(c -> c.getName().equals("level2")).findFirst().orElseThrow(); + FileTreeNode level1SiblingNode = level1Node.getChildren().stream() + .filter(c -> c.getName().equals("level1Sibling")).findFirst().orElseThrow(); + assertNotNull(level2Node.getChildren()); + assertNull(level1SiblingNode.getChildren()); + + assertEquals(1, level2Node.getChildren().size()); + assertEquals("target.xml", level2Node.getChildren().getFirst().getName()); + assertNull(level2Node.getChildren().getFirst().getChildren()); + } + + @Test + @DisplayName("Three-level deep path: spine is correctly threaded through all intermediate directories") + void getAncestorPath_threeLevel_spineThreadedCorrectly() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path l1 = Files.createDirectory(tempProjectRoot.resolve("l1")); + Path l2 = Files.createDirectory(l1.resolve("l2")); + Path l3 = Files.createDirectory(l2.resolve("l3")); + Files.writeString(l3.resolve("deep.xml"), ""); + Files.createDirectory(l2.resolve("l2Sibling")); + Files.createDirectory(l1.resolve("l1Sibling")); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, l3.toAbsolutePath().toString()); + + FileTreeNode l1Node = root.getChildren().stream().filter(c -> c.getName().equals("l1")).findFirst().orElseThrow(); + assertNotNull(l1Node.getChildren()); + + FileTreeNode l1SiblingNode = l1Node.getChildren().stream() + .filter(c -> c.getName().equals("l1Sibling")).findFirst().orElseThrow(); + assertNull(l1SiblingNode.getChildren()); + + FileTreeNode l2Node = l1Node.getChildren().stream().filter(c -> c.getName().equals("l2")).findFirst().orElseThrow(); + assertNotNull(l2Node.getChildren()); + + FileTreeNode l2SiblingNode = l2Node.getChildren().stream() + .filter(c -> c.getName().equals("l2Sibling")).findFirst().orElseThrow(); + assertNull(l2SiblingNode.getChildren()); + + FileTreeNode l3Node = l2Node.getChildren().stream().filter(c -> c.getName().equals("l3")).findFirst().orElseThrow(); + assertNotNull(l3Node.getChildren()); + assertEquals(1, l3Node.getChildren().size()); + assertEquals("deep.xml", l3Node.getChildren().getFirst().getName()); + assertNull(l3Node.getChildren().getFirst().getChildren()); + } + + @Test + @DisplayName("Multiple siblings at each spine level: only the single spine child has children set") + void getAncestorPath_manySiblingsAtEachLevel_exactlyOneSpineChildPerLevel() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path target = Files.createDirectory(tempProjectRoot.resolve("target")); + Files.writeString(target.resolve("file.xml"), ""); + for (int i = 0; i < 4; i++) { + Files.createDirectory(tempProjectRoot.resolve("sibling" + i)); + } + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, target.toAbsolutePath().toString()); + + long spineCount = root.getChildren().stream().filter(c -> c.getChildren() != null).count(); + assertEquals(1, spineCount); + + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getChildren() != null).findFirst().orElseThrow(); + assertEquals("target", spineChild.getName()); + } + + @Test + @DisplayName("Should use relative paths for all nodes when not in local environment") + void getAncestorPath_nonLocalEnvironment_usesRelativePaths() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(false); + + Path subDir = Files.createDirectory(tempProjectRoot.resolve("subdir")); + Files.writeString(subDir.resolve("file.xml"), ""); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, subDir.toAbsolutePath().toString()); + + assertFalse(Paths.get(root.getPath()).isAbsolute()); + + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("subdir")).findFirst().orElseThrow(); + assertFalse(Paths.get(spineChild.getPath()).isAbsolute()); + assertFalse(Paths.get(spineChild.getChildren().getFirst().getPath()).isAbsolute()); + } + + @Test + @DisplayName("Should extract adapter names from .xml files at the target level") + void getAncestorPath_xmlFilesAtTargetLevel_haveAdapterNamesExtracted() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path subDir = Files.createDirectory(tempProjectRoot.resolve("subdir")); + Files.writeString(subDir.resolve("config.xml"), + ""); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, subDir.toAbsolutePath().toString()); + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("subdir")).findFirst().orElseThrow(); + FileTreeNode xmlNode = spineChild.getChildren().stream() + .filter(c -> c.getName().equals("config.xml")).findFirst().orElseThrow(); + + assertNotNull(xmlNode.getAdapterNames()); + assertEquals(List.of("MyAdapter"), xmlNode.getAdapterNames()); + } + + @Test + @DisplayName("Non-.xml files at target level should have null adapterNames") + void getAncestorPath_nonXmlFilesAtTargetLevel_haveNoAdapterNames() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path subDir = Files.createDirectory(tempProjectRoot.resolve("subdir")); + Files.writeString(subDir.resolve("readme.txt"), "hello"); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, subDir.toAbsolutePath().toString()); + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("subdir")).findFirst().orElseThrow(); + FileTreeNode txtNode = spineChild.getChildren().stream() + .filter(c -> c.getName().equals("readme.txt")).findFirst().orElseThrow(); + + assertNull(txtNode.getAdapterNames()); + } + + @Test + @DisplayName("Node types should be correct: DIRECTORY for directories, FILE for files at target level") + void getAncestorPath_nodeTypesAreCorrect() throws IOException, ApiException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + + Path subDir = Files.createDirectory(tempProjectRoot.resolve("subdir")); + Files.writeString(subDir.resolve("file.xml"), ""); + Files.createDirectory(subDir.resolve("nested")); + + ConfigurationProject project = new ConfigurationProject(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(configurationProjectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode root = fileTreeService.getAncestorPath(TEST_PROJECT_NAME, subDir.toAbsolutePath().toString()); + FileTreeNode spineChild = root.getChildren().stream() + .filter(c -> c.getName().equals("subdir")).findFirst().orElseThrow(); + + assertEquals(NodeType.DIRECTORY, spineChild.getType()); + + FileTreeNode fileNode = spineChild.getChildren().stream() + .filter(c -> c.getName().equals("file.xml")).findFirst().orElseThrow(); + FileTreeNode nestedNode = spineChild.getChildren().stream() + .filter(c -> c.getName().equals("nested")).findFirst().orElseThrow(); + + assertEquals(NodeType.FILE, fileNode.getType()); + assertEquals(NodeType.DIRECTORY, nestedNode.getType()); + } + private void stubCreateProjectDirectory() throws IOException { when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { String path = invocation.getArgument(0);