diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx index f204442b..0a7f93f3 100644 --- a/src/main/frontend/app/components/directory-picker/directory-picker.tsx +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -21,6 +21,7 @@ export default function DirectoryPicker({ initialPath, }: Readonly) { const [currentPath, setCurrentPath] = useState('') + const [parentPath, setParentPath] = useState('') const [entries, setEntries] = useState([]) const [selectedEntry, setSelectedEntry] = useState(null) const [loading, setLoading] = useState(false) @@ -32,11 +33,11 @@ export default function DirectoryPicker({ setSelectedEntry(null) try { const result = await filesystemService.browse(path) - setEntries(result) - setCurrentPath(path) + setEntries(result.entries) + setCurrentPath(result.resolvedPath) + setParentPath(result.parentPath) } catch (error_) { - const status = error_ instanceof ApiError ? error_.status : 0 - if (status === 403) { + if (error_ instanceof ApiError && error_.status === 403) { setError('Access denied') } else { setError(error_ instanceof Error ? error_.message : 'Failed to load directories') @@ -46,6 +47,10 @@ export default function DirectoryPicker({ } }, []) + const handleNavigateUp = () => { + loadEntries(parentPath) + } + useEffect(() => { if (isOpen) { setSelectedEntry(null) @@ -55,23 +60,7 @@ export default function DirectoryPicker({ if (!isOpen) return null - const isRoot = !currentPath - const canGoUp = !isRoot - - const handleNavigateUp = () => { - if (/^[a-zA-Z]:[/\\]?$/.test(currentPath) || currentPath === '/') { - loadEntries('') - return - } - const parentPath = currentPath.replace(/[\\/][^\\/]*$/, '') - if (!parentPath || parentPath === currentPath) { - loadEntries('') - } else if (/^[a-zA-Z]:$/.test(parentPath)) { - loadEntries(`${parentPath}\\`) - } else { - loadEntries(parentPath) - } - } + const canGoUp = parentPath !== '' const handleClick = (entry: FilesystemEntry) => { setSelectedEntry(entry.path) diff --git a/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx index 910060b4..393233a0 100644 --- a/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx @@ -24,18 +24,15 @@ export default function CloneProjectModal({ const [showPicker, setShowPicker] = useState(false) useEffect(() => { - if (isOpen && isLocal) { - if (initialPath) { - setLocation(initialPath) - } else { - filesystemService - .getDefaultPath() - .then(setLocation) - .catch(() => setLocation('')) - } - } else if (isOpen) { - setLocation(initialPath ?? '') + if (!isOpen || !isLocal) { + if (isOpen) setLocation(initialPath ?? '') + return } + + filesystemService + .resolveNearestAccessiblePath(initialPath ?? '') + .then(setLocation) + .catch(() => setLocation('')) }, [isOpen, isLocal, initialPath]) if (!isOpen) return null diff --git a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx index 408785de..2d4e9b4c 100644 --- a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx @@ -23,18 +23,15 @@ export default function NewProjectModal({ const [showPicker, setShowPicker] = useState(false) useEffect(() => { - if (isOpen && isLocal) { - if (initialPath) { - setLocation(initialPath) - } else { - filesystemService - .getDefaultPath() - .then(setLocation) - .catch(() => setLocation('')) - } - } else if (isOpen) { - setLocation(initialPath ?? '') + if (!isOpen || !isLocal) { + if (isOpen) setLocation(initialPath ?? '') + return } + + filesystemService + .resolveNearestAccessiblePath(initialPath ?? '') + .then(setLocation) + .catch(() => setLocation('')) }, [isOpen, isLocal, initialPath]) if (!isOpen) return null diff --git a/src/main/frontend/app/services/filesystem-service.ts b/src/main/frontend/app/services/filesystem-service.ts index 4e36bc7b..e59cb5f0 100644 --- a/src/main/frontend/app/services/filesystem-service.ts +++ b/src/main/frontend/app/services/filesystem-service.ts @@ -1,13 +1,13 @@ import { apiFetch } from '~/utils/api' -import type { FilesystemEntry } from '~/types/filesystem.types' +import type { BrowseResult } from '~/types/filesystem.types' export const filesystemService = { - async browse(path = ''): Promise { + async browse(path = ''): Promise { return apiFetch(`/filesystem/browse?path=${encodeURIComponent(path)}`) }, - async getDefaultPath(): Promise { - const result = await apiFetch<{ path: string }>('/filesystem/default-path') - return result.path + async resolveNearestAccessiblePath(path: string): Promise { + const result = await this.browse(path) + return result.resolvedPath }, } diff --git a/src/main/frontend/app/types/filesystem.types.ts b/src/main/frontend/app/types/filesystem.types.ts index 983b22ef..52e8ddb1 100644 --- a/src/main/frontend/app/types/filesystem.types.ts +++ b/src/main/frontend/app/types/filesystem.types.ts @@ -1,5 +1,11 @@ export type EntryType = 'DIRECTORY' | 'FILE' +export interface BrowseResult { + resolvedPath: string + parentPath: string + entries: FilesystemEntry[] +} + export interface FilesystemEntry { name: string path: string diff --git a/src/main/java/org/frankframework/flow/filesystem/BrowseResult.java b/src/main/java/org/frankframework/flow/filesystem/BrowseResult.java new file mode 100644 index 00000000..edda199d --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/BrowseResult.java @@ -0,0 +1,5 @@ +package org.frankframework.flow.filesystem; + +import java.util.List; + +public record BrowseResult(String resolvedPath, String parentPath, List entries) {} diff --git a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java index 521af3a3..7ccc037a 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java +++ b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java @@ -1,6 +1,7 @@ package org.frankframework.flow.filesystem; import java.io.IOException; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.List; @@ -17,6 +18,17 @@ public interface FileSystemStorage { */ List listDirectory(String path) throws IOException; + /** + * Returns entries for the given path. If the path does not exist, walks up to + * the nearest accessible ancestor. Falls back to roots if none found. + */ + default BrowseResult browse(String path) throws IOException { + if (path == null || path.isBlank()) { + return new BrowseResult("", "", listRoots()); + } + return browseNearestAccessible(path); + } + String readFile(String path) throws IOException; String readFileType(String path) throws IOException; @@ -53,4 +65,24 @@ public interface FileSystemStorage { default String toRelativePath(String absolutePath) { return absolutePath; } + + + private BrowseResult browseNearestAccessible(String path) throws IOException { + try { + return new BrowseResult(path, parentPath(path), listDirectory(path)); + } catch (NoSuchFileException e) { + String parent = parentPath(path); + return parent.isEmpty() ? new BrowseResult("", "", listRoots()) : browseNearestAccessible(parent); + } + } + + private static String parentPath(String path) { + String normalized = path.replace('/', '\\'); + if (normalized.matches("[a-zA-Z]:[/\\\\]?")) return ""; + int lastSep = normalized.lastIndexOf('\\'); + if (lastSep < 0) return ""; + String parent = normalized.substring(0, lastSep); + if (parent.matches("[a-zA-Z]:")) return parent + "\\"; + return parent; + } } diff --git a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java index 0baf3000..ff93946b 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java +++ b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.nio.file.AccessDeniedException; -import java.util.List; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -22,21 +21,13 @@ public FilesystemController(FileSystemStorage fileSystemStorage) { } @GetMapping("/browse") - public ResponseEntity> browse(@RequestParam(required = false, defaultValue = "") String path) + public ResponseEntity browse(@RequestParam(required = false, defaultValue = "") String path) throws IOException { - - List entries; - if (path.isBlank()) { - entries = fileSystemStorage.listRoots(); - } else { - try { - entries = fileSystemStorage.listDirectory(path); - } catch (AccessDeniedException e) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } + try { + return ResponseEntity.ok(fileSystemStorage.browse(path)); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - - return ResponseEntity.ok(entries); } @GetMapping("/default-path") diff --git a/src/test/java/org/frankframework/flow/filesystem/FileSystemStorageBrowseTest.java b/src/test/java/org/frankframework/flow/filesystem/FileSystemStorageBrowseTest.java new file mode 100644 index 00000000..d681370a --- /dev/null +++ b/src/test/java/org/frankframework/flow/filesystem/FileSystemStorageBrowseTest.java @@ -0,0 +1,163 @@ +package org.frankframework.flow.filesystem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileSystemStorageBrowseTest { + private StubStorage storage; + + private static final FilesystemEntry ROOT_ENTRY = new FilesystemEntry("root", "C:\\", "DIRECTORY", false); + private static final FilesystemEntry CHILD_ENTRY = new FilesystemEntry("child", "C:\\existing", "DIRECTORY", false); + + @BeforeEach + void setUp() { + storage = new StubStorage(); + } + + @Test + void browseNullPathReturnsRoots() throws IOException { + when(storage.delegate.listRoots()).thenReturn(List.of(ROOT_ENTRY)); + + BrowseResult result = storage.browse(null); + + assertEquals("", result.resolvedPath()); + assertEquals("", result.parentPath()); + assertEquals(List.of(ROOT_ENTRY), result.entries()); + } + + @Test + void browseBlankPathReturnsRoots() throws IOException { + when(storage.delegate.listRoots()).thenReturn(List.of(ROOT_ENTRY)); + + BrowseResult result = storage.browse(" "); + + assertEquals("", result.resolvedPath()); + assertEquals("", result.parentPath()); + } + + @Test + void browseExistingPathReturnsEntriesAndParent() throws IOException { + when(storage.delegate.listDirectory("C:\\existing")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("C:\\existing"); + + assertEquals("C:\\existing", result.resolvedPath()); + assertEquals("C:\\", result.parentPath()); + assertEquals(List.of(CHILD_ENTRY), result.entries()); + } + + @Test + void browseWindowsDriveRootHasEmptyParent() throws IOException { + when(storage.delegate.listDirectory("C:\\")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("C:\\"); + + assertEquals("C:\\", result.resolvedPath()); + assertEquals("", result.parentPath()); + } + + @Test + void browseMissingPathWalksUpToParent() throws IOException { + when(storage.delegate.listDirectory("C:\\missing")).thenThrow(new NoSuchFileException("C:\\missing")); + when(storage.delegate.listDirectory("C:\\")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("C:\\missing"); + + assertEquals("C:\\", result.resolvedPath()); + assertEquals("", result.parentPath()); + assertEquals(List.of(CHILD_ENTRY), result.entries()); + } + + @Test + void browseMissingNestedPathWalksUpMultipleLevels() throws IOException { + when(storage.delegate.listDirectory("C:\\a\\b\\c")).thenThrow(new NoSuchFileException("C:\\a\\b\\c")); + when(storage.delegate.listDirectory("C:\\a\\b")).thenThrow(new NoSuchFileException("C:\\a\\b")); + when(storage.delegate.listDirectory("C:\\a")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("C:\\a\\b\\c"); + + assertEquals("C:\\a", result.resolvedPath()); + assertEquals("C:\\", result.parentPath()); + } + + @Test + void browseMissingPathWithNoAccessibleAncestorFallsBackToRoots() throws IOException { + when(storage.delegate.listDirectory("missing")).thenThrow(new NoSuchFileException("missing")); + when(storage.delegate.listRoots()).thenReturn(List.of(ROOT_ENTRY)); + + BrowseResult result = storage.browse("missing"); + + assertEquals("", result.resolvedPath()); + assertEquals("", result.parentPath()); + assertEquals(List.of(ROOT_ENTRY), result.entries()); + } + + @Test + void browseRelativePathReturnsEntriesAndParent() throws IOException { + when(storage.delegate.listDirectory("projects/myproject")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("projects/myproject"); + + assertEquals("projects/myproject", result.resolvedPath()); + assertEquals("projects", result.parentPath()); + } + + @Test + void browseRelativeSingleSegmentHasEmptyParent() throws IOException { + when(storage.delegate.listDirectory("projects")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("projects"); + + assertEquals("projects", result.resolvedPath()); + assertEquals("", result.parentPath()); + } + + @Test + void browseExistingPathDoesNotCallListRoots() throws IOException { + when(storage.delegate.listDirectory("C:\\existing")).thenReturn(List.of(CHILD_ENTRY)); + + storage.browse("C:\\existing"); + + verify(storage.delegate, never()).listRoots(); + } + + @Test + void browseReturnsEmptyEntriesForEmptyDirectory() throws IOException { + when(storage.delegate.listDirectory("C:\\existing")).thenReturn(List.of()); + + BrowseResult result = storage.browse("C:\\existing"); + + assertTrue(result.entries().isEmpty()); + } + + /** + * Minimal concrete stub so default methods on FileSystemStorage execute normally. + * Abstract methods delegate to a Mockito mock for per-test control. + */ + private static class StubStorage implements FileSystemStorage { + final FileSystemStorage delegate = mock(FileSystemStorage.class); + + @Override public boolean isLocalEnvironment() { return true; } + @Override public List listRoots() { return delegate.listRoots(); } + @Override public List listDirectory(String path) throws IOException { return delegate.listDirectory(path); } + @Override public String readFile(String path) { return null; } + @Override public String readFileType(String path) { return null; } + @Override public void writeFile(String path, String content) {} + @Override public Path createProjectDirectory(String path) { return null; } + @Override public Path toAbsolutePath(String path) { return null; } + @Override public Path createFile(String path) { return null; } + @Override public void delete(String path) {} + @Override public Path rename(String oldPath, String newPath) { return null; } + } +}