Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import NameInputDialog from '~/components/file-structure/name-input-dialog'
import { filesystemService } from '~/services/filesystem-service'
import type { FilesystemEntry } from '~/types/filesystem.types'
import { ApiError } from '~/utils/api'
import { useDirectoryWatcher } from '~/hooks/use-file-watcher'
import Button from '../inputs/button'

interface DirectoryPickerProperties {
Expand Down Expand Up @@ -50,6 +51,8 @@ export default function DirectoryPicker({
}
}, [])

useDirectoryWatcher(isOpen ? currentPath : null, () => void loadEntries(currentPath))

useEffect(() => {
if (isOpen) {
setSelectedEntry(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,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 { useFileWatcher } from '~/hooks/use-file-watcher'
import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities'
import type { ContextMenuState } from './use-file-tree-context-menu'

Expand Down Expand Up @@ -65,6 +66,10 @@ export default function EditorFileStructure() {
expandedItemsRef.current = editorExpandedItems
}, [editorExpandedItems])

useFileWatcher(project?.name ?? null, () => {
if (dataProvider) void dataProvider.reloadDirectory('root')
})

useEffect(() => {
if (!dataProvider) {
setRootPath(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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'
import { useShortcut } from '~/hooks/use-shortcut'
import { useFileWatcher } from '~/hooks/use-file-watcher'
import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities'
import type { StudioContextMenuState } from './use-studio-context-menu'

Expand Down Expand Up @@ -96,6 +97,10 @@ export default function StudioFileStructure() {
expandedItemsRef.current = studioExpandedItems
}, [studioExpandedItems])

useFileWatcher(project?.name ?? null, () => {
if (dataProvider) void dataProvider.reloadDirectory('root')
})

const studioContextMenu = useStudioContextMenu({
projectName: project?.name,
dataProvider,
Expand Down
31 changes: 31 additions & 0 deletions src/main/frontend/app/hooks/use-file-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react'
import { apiUrl } from '~/utils/api'

function useSseWatcher(url: string | null, onFileChange: () => void) {
const callbackRef = useRef(onFileChange)
callbackRef.current = onFileChange

useEffect(() => {
if (!url) return

const eventSource = new EventSource(url)

eventSource.addEventListener('file-change', () => {
callbackRef.current()
})

return () => {
eventSource.close()
}
}, [url])
}

export function useFileWatcher(projectName: string | null | undefined, onFileChange: () => void) {
const url = projectName ? apiUrl(`/projects/${projectName}/watch`) : null
useSseWatcher(url, onFileChange)
}

export function useDirectoryWatcher(path: string | null, onFileChange: () => void) {
const url = path ? apiUrl(`/filesystem/watch?path=${encodeURIComponent(path)}`) : null
useSseWatcher(url, onFileChange)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/projects/{projectName}")
public class FileTreeController {

private final FileTreeService fileTreeService;
private final FileWatcherService fileWatcherService;

public FileTreeController(FileTreeService fileTreeService) {
public FileTreeController(FileTreeService fileTreeService, FileWatcherService fileWatcherService) {
this.fileTreeService = fileTreeService;
this.fileWatcherService = fileWatcherService;
}

@GetMapping("/tree")
Expand Down Expand Up @@ -60,4 +63,9 @@ public ResponseEntity<FileTreeNode> createFolder(@PathVariable String projectNam
FileTreeNode node = fileTreeService.createFolder(projectName, dto.path());
return ResponseEntity.status(HttpStatus.CREATED.value()).body(node);
}

@GetMapping("/watch")
public SseEmitter watchProject(@PathVariable String projectName) {
return fileWatcherService.subscribeToProject(projectName);
}
}
209 changes: 209 additions & 0 deletions src/main/java/org/frankframework/flow/file/FileWatcherService.java
Comment thread
stijnpotters1 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package org.frankframework.flow.file;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import lombok.extern.log4j.Log4j2;
import org.frankframework.flow.filesystem.FileSystemStorage;
import org.frankframework.flow.project.ConfigurationProject;
import org.frankframework.flow.project.ConfigurationProjectService;
import org.frankframework.flow.sse.SseChannelService;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@Log4j2
@Service
public class FileWatcherService {

private static final long DEBOUNCE_DELAY_MS = 150;
private static final Set<String> IGNORED_DIRECTORIES = Set.of(".git", "target", "node_modules");

private final FileSystemStorage fileSystemStorage;
private final FileTreeService fileTreeService;
private final ConfigurationProjectService configurationProjectService;
private final SseChannelService sseChannelService;

private WatchService watchService;

private final Map<WatchKey, String> watchKeyChannels = new ConcurrentHashMap<>();
private final Map<String, Runnable> channelCallbacks = new ConcurrentHashMap<>();
private final Map<String, ScheduledFuture<?>> pendingBroadcasts = new ConcurrentHashMap<>();

private final ScheduledExecutorService debounceExecutor = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("file-watcher-debounce", 0).factory()
);

public FileWatcherService(
FileSystemStorage fileSystemStorage,
FileTreeService fileTreeService,
ConfigurationProjectService configurationProjectService,
SseChannelService sseChannelService
) {
this.fileSystemStorage = fileSystemStorage;
this.fileTreeService = fileTreeService;
this.configurationProjectService = configurationProjectService;
this.sseChannelService = sseChannelService;
}

@PostConstruct
public void start() {
if (!fileSystemStorage.isLocalEnvironment()) {
return;
}

try {
watchService = FileSystems.getDefault().newWatchService();
Thread.ofVirtual().name("file-watcher").start(this::watchLoop);
log.info("File watcher service started");
} catch (IOException exception) {
log.error("Failed to start file watcher", exception);
}
}

@PreDestroy
public void stop() {
debounceExecutor.shutdownNow();
if (watchService != null) {
try {
watchService.close();
} catch (IOException exception) {
log.warn("Failed to close watch service", exception);
}
}
}

public SseEmitter subscribeToProject(String projectName) {
if (watchService == null) {
return sseChannelService.subscribe(projectName);
}
try {
ConfigurationProject project = configurationProjectService.getProject(projectName);
Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath());
String channelId = projectPath.toString();
channelCallbacks.put(channelId, () -> fileTreeService.invalidateTreeCache(projectName));
registerRecursively(projectPath, channelId);

return sseChannelService.subscribe(channelId);
} catch (Exception exception) {
log.warn("Failed to register project for watching: {}", projectName, exception);
return sseChannelService.subscribe(projectName);
}
}

public SseEmitter subscribeToPath(Path absolutePath) throws IOException {
String channelId = absolutePath.toString();
if (watchService != null && Files.isDirectory(absolutePath)) {
WatchKey key = absolutePath.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY
);
watchKeyChannels.put(key, channelId);
}
return sseChannelService.subscribe(channelId);
}

private void registerRecursively(Path dir, String channelId) throws IOException {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {

@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException {
String name = directory.getFileName() != null ? directory.getFileName().toString() : "";
if (IGNORED_DIRECTORIES.contains(name)) {
return FileVisitResult.SKIP_SUBTREE;
}
WatchKey key = directory.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY
);
watchKeyChannels.put(key, channelId);
return FileVisitResult.CONTINUE;
}
});
}

private void watchLoop() {
while (!Thread.currentThread().isInterrupted()) {
WatchKey key = takeNextKey();
if (key == null) {
break;
}

String channelId = watchKeyChannels.get(key);
if (channelId != null) {
registerNewSubdirectories(key, channelId);
scheduleBroadcast(channelId);
}

if (!key.reset()) {
watchKeyChannels.remove(key);
}
}
}

private WatchKey takeNextKey() {
try {
return watchService.take();
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
return null;
} catch (ClosedWatchServiceException _) {
return null;
}
}

private void registerNewSubdirectories(WatchKey key, String channelId) {
Path watchedDir = (Path) key.watchable();
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() != StandardWatchEventKinds.ENTRY_CREATE) {
continue;
}

Path created = watchedDir.resolve(((WatchEvent<Path>) event).context());
if (Files.isDirectory(created)) {
try {
registerRecursively(created, channelId);
} catch (IOException _) {
log.warn("Failed to register new directory: {}", created);
}
}
}
}

private void scheduleBroadcast(String channelId) {
ScheduledFuture<?> existing = pendingBroadcasts.remove(channelId);
if (existing != null) {
existing.cancel(false);
}
pendingBroadcasts.put(channelId, debounceExecutor.schedule(() -> {
Runnable callback = channelCallbacks.get(channelId);
if (callback != null) {
callback.run();
}

sseChannelService.broadcast(channelId, SseEmitter.event().name("file-change").data("changed"));
pendingBroadcasts.remove(channelId);
}, DEBOUNCE_DELAY_MS, TimeUnit.MILLISECONDS));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@

import java.io.IOException;
import java.nio.file.AccessDeniedException;
import org.frankframework.flow.file.FileWatcherService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/filesystem")
public class FilesystemController {

private final FileSystemStorage fileSystemStorage;
private final FileWatcherService fileWatcherService;

public FilesystemController(FileSystemStorage fileSystemStorage) {
public FilesystemController(FileSystemStorage fileSystemStorage, FileWatcherService fileWatcherService) {
this.fileSystemStorage = fileSystemStorage;
this.fileWatcherService = fileWatcherService;
}

@GetMapping("/browse")
Expand All @@ -30,6 +34,11 @@ public ResponseEntity<BrowseResult> browse(@RequestParam(required = false, defau
}
}

@GetMapping("/watch")
public SseEmitter watch(@RequestParam String path) throws IOException {
return fileWatcherService.subscribeToPath(fileSystemStorage.toAbsolutePath(path));
}

@PostMapping("/mkdir")
public ResponseEntity<Void> mkdir(@RequestParam String path) throws IOException {
try {
Expand Down
Loading
Loading