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-configuration-modal.tsx b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx index fc76139e..48744d7f 100644 --- a/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/clone-configuration-modal.tsx @@ -24,18 +24,15 @@ export default function CloneConfigurationModal({ 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-configuration-modal.tsx b/src/main/frontend/app/routes/projectlanding/new-configuration-modal.tsx index ccb89336..daa5604f 100644 --- a/src/main/frontend/app/routes/projectlanding/new-configuration-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/new-configuration-modal.tsx @@ -25,18 +25,15 @@ export default function NewConfigurationModal({ 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..7d645b2a 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,68 @@ public interface FileSystemStorage { default String toRelativePath(String absolutePath) { return absolutePath; } + + + private BrowseResult browseNearestAccessible(String path) throws IOException { + try { + return new BrowseResult(path, getParentPath(path), listDirectory(path)); + } catch (NoSuchFileException e) { + String parent = getParentPath(path); + return parent.isEmpty() ? new BrowseResult("", "", listRoots()) : browseNearestAccessible(parent); + } + } + + private static String getParentPath(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + + if (path.startsWith("/")) { + return getUnixParent(path); + } + + if (path.matches("^[a-zA-Z]:.*")) { + return getWindowsParent(path); + } + + if (isWindows()) { + return getWindowsParent(path); + } else { + return getUnixParent(path); + } + } + + private static String getUnixParent(String path) { + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + if (path.equals("/")) return ""; + + int lastSep = path.lastIndexOf('/'); + if (lastSep < 0) return ""; + if (lastSep == 0) return "/"; + + return path.substring(0, lastSep); + } + + private static String getWindowsParent(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; + } + + private static boolean isWindows() { + String os = System.getProperty("os.name").toLowerCase(); + return os.contains("win"); + } } diff --git a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java index 0baf3000..389b9d74 100644 --- a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java +++ b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java @@ -2,8 +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; import org.springframework.web.bind.annotation.GetMapping; @@ -22,26 +20,12 @@ 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") - public ResponseEntity> defaultPath() { - String home = System.getProperty("user.home"); - return ResponseEntity.ok(Map.of("path", home)); } } 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..2f9161b3 --- /dev/null +++ b/src/test/java/org/frankframework/flow/filesystem/FileSystemStorageBrowseTest.java @@ -0,0 +1,204 @@ +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 browseLinuxAbsolutePathReturnsEntriesAndParent() throws IOException { + when(storage.delegate.listDirectory("/home/user")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("/home/user"); + + assertEquals("/home/user", result.resolvedPath()); + assertEquals("/home", result.parentPath()); + } + + @Test + void browseLinuxFirstLevelDirectoryHasRootAsParent() throws IOException { + when(storage.delegate.listDirectory("/home")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("/home"); + + assertEquals("/home", result.resolvedPath()); + assertEquals("/", result.parentPath()); + } + + @Test + void browseLinuxRootHasEmptyParent() throws IOException { + when(storage.delegate.listDirectory("/")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("/"); + + assertEquals("/", result.resolvedPath()); + assertEquals("", result.parentPath()); + } + + @Test + void browseLinuxMissingPathWalksUpCorrectly() throws IOException { + when(storage.delegate.listDirectory("/home/user/missing")).thenThrow(new NoSuchFileException("/home/user/missing")); + when(storage.delegate.listDirectory("/home/user")).thenReturn(List.of(CHILD_ENTRY)); + + BrowseResult result = storage.browse("/home/user/missing"); + + assertEquals("/home/user", result.resolvedPath()); + assertEquals("/home", result.parentPath()); + } + + @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; } + } +} diff --git a/src/test/java/org/frankframework/flow/filesystem/FilesystemControllerTest.java b/src/test/java/org/frankframework/flow/filesystem/FilesystemControllerTest.java index df2fbaa6..e9010953 100644 --- a/src/test/java/org/frankframework/flow/filesystem/FilesystemControllerTest.java +++ b/src/test/java/org/frankframework/flow/filesystem/FilesystemControllerTest.java @@ -1,6 +1,5 @@ package org.frankframework.flow.filesystem; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -28,49 +27,41 @@ class FilesystemControllerTest { @Test void browseWithoutPathReturnsRoots() throws Exception { - when(fileSystemStorage.listRoots()) - .thenReturn(List.of(new FilesystemEntry("C:", "C:", "directory", true))); + when(fileSystemStorage.browse("")) + .thenReturn(new BrowseResult("", "", List.of(new FilesystemEntry("C:", "C:", "directory", true)))); mockMvc.perform(get("/api/filesystem/browse")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("C:")) - .andExpect(jsonPath("$[0].path").value("C:")) - .andExpect(jsonPath("$[0].type").value("directory")) - .andExpect(jsonPath("$[0].projectRoot").value(true)); + .andExpect(jsonPath("$.entries[0].name").value("C:")) + .andExpect(jsonPath("$.entries[0].path").value("C:")) + .andExpect(jsonPath("$.entries[0].type").value("directory")) + .andExpect(jsonPath("$.entries[0].projectRoot").value(true)); - verify(fileSystemStorage).listRoots(); - verify(fileSystemStorage, never()).listDirectory(org.mockito.ArgumentMatchers.anyString()); + verify(fileSystemStorage).browse(""); } @Test void browseWithPathReturnsDirectoryEntries() throws Exception { String path = "workspace/project"; - when(fileSystemStorage.listDirectory(path)) - .thenReturn(List.of(new FilesystemEntry("configurations", path + "/configurations", "directory", false))); + when(fileSystemStorage.browse(path)) + .thenReturn(new BrowseResult(path, "workspace", List.of(new FilesystemEntry("configurations", path + "/configurations", "directory", false)))); mockMvc.perform(get("/api/filesystem/browse").param("path", path)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("configurations")) - .andExpect(jsonPath("$[0].path").value(path + "/configurations")); + .andExpect(jsonPath("$.entries[0].name").value("configurations")) + .andExpect(jsonPath("$.entries[0].path").value(path + "/configurations")); - verify(fileSystemStorage).listDirectory(path); + verify(fileSystemStorage).browse(path); } @Test void browseWithInaccessiblePathReturnsForbidden() throws Exception { String path = "protected"; - when(fileSystemStorage.listDirectory(path)).thenThrow(new AccessDeniedException(path)); + when(fileSystemStorage.browse(path)).thenThrow(new AccessDeniedException(path)); mockMvc.perform(get("/api/filesystem/browse").param("path", path)) .andExpect(status().isForbidden()); - verify(fileSystemStorage).listDirectory(path); - } - - @Test - void defaultPathReturnsUserHomeDirectory() throws Exception { - mockMvc.perform(get("/api/filesystem/default-path")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.path").value(System.getProperty("user.home"))); + verify(fileSystemStorage).browse(path); } }