Skip to content

Commit 15eb36a

Browse files
Implement file watching functionality in filesystem and directory picker (#497)
Signed-off-by: Stijn Potters <stijn.potters1@gmail.com>
1 parent 392234d commit 15eb36a

12 files changed

Lines changed: 583 additions & 2 deletions

File tree

src/main/frontend/app/components/directory-picker/directory-picker.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import NameInputDialog from '~/components/file-structure/name-input-dialog'
44
import { filesystemService } from '~/services/filesystem-service'
55
import type { FilesystemEntry } from '~/types/filesystem.types'
66
import { ApiError } from '~/utils/api'
7+
import { useDirectoryWatcher } from '~/hooks/use-file-watcher'
78
import Button from '../inputs/button'
89

910
interface DirectoryPickerProperties {
@@ -50,6 +51,8 @@ export default function DirectoryPicker({
5051
}
5152
}, [])
5253

54+
useDirectoryWatcher(isOpen ? currentPath : null, () => void loadEntries(currentPath))
55+
5356
useEffect(() => {
5457
if (isOpen) {
5558
setSelectedEntry(null)

src/main/frontend/app/components/file-structure/editor-file-structure.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import CodeFileIcon from '../../../icons/solar/Code File.svg?react'
1212
import TrashBinIcon from '../../../icons/solar/Trash Bin.svg?react'
1313
import Pen from '../../../icons/solar/Pen.svg?react'
1414
import { useShortcut } from '~/hooks/use-shortcut'
15+
import { useFileWatcher } from '~/hooks/use-file-watcher'
1516
import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities'
1617
import type { ContextMenuState } from './use-file-tree-context-menu'
1718

@@ -65,6 +66,10 @@ export default function EditorFileStructure() {
6566
expandedItemsRef.current = editorExpandedItems
6667
}, [editorExpandedItems])
6768

69+
useFileWatcher(project?.name ?? null, () => {
70+
if (dataProvider) void dataProvider.reloadDirectory('root')
71+
})
72+
6873
useEffect(() => {
6974
if (!dataProvider) {
7075
setRootPath(null)

src/main/frontend/app/components/file-structure/studio-file-structure.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '/styles/editor-files.css'
1414
import AltArrowRightIcon from '../../../icons/solar/Alt Arrow Right.svg?react'
1515
import AltArrowDownIcon from '../../../icons/solar/Alt Arrow Down.svg?react'
1616
import { useShortcut } from '~/hooks/use-shortcut'
17+
import { useFileWatcher } from '~/hooks/use-file-watcher'
1718
import { getAncestorIds, isVisibleInTree, selectAndReveal, toTreeItemId } from './tree-utilities'
1819
import type { StudioContextMenuState } from './use-studio-context-menu'
1920

@@ -96,6 +97,10 @@ export default function StudioFileStructure() {
9697
expandedItemsRef.current = studioExpandedItems
9798
}, [studioExpandedItems])
9899

100+
useFileWatcher(project?.name ?? null, () => {
101+
if (dataProvider) void dataProvider.reloadDirectory('root')
102+
})
103+
99104
const studioContextMenu = useStudioContextMenu({
100105
projectName: project?.name,
101106
dataProvider,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useRef } from 'react'
2+
import { apiUrl } from '~/utils/api'
3+
4+
function useSseWatcher(url: string | null, onFileChange: () => void) {
5+
const callbackRef = useRef(onFileChange)
6+
callbackRef.current = onFileChange
7+
8+
useEffect(() => {
9+
if (!url) return
10+
11+
const eventSource = new EventSource(url)
12+
13+
eventSource.addEventListener('file-change', () => {
14+
callbackRef.current()
15+
})
16+
17+
return () => {
18+
eventSource.close()
19+
}
20+
}, [url])
21+
}
22+
23+
export function useFileWatcher(projectName: string | null | undefined, onFileChange: () => void) {
24+
const url = projectName ? apiUrl(`/projects/${projectName}/watch`) : null
25+
useSseWatcher(url, onFileChange)
26+
}
27+
28+
export function useDirectoryWatcher(path: string | null, onFileChange: () => void) {
29+
const url = path ? apiUrl(`/filesystem/watch?path=${encodeURIComponent(path)}`) : null
30+
useSseWatcher(url, onFileChange)
31+
}

src/main/java/org/frankframework/flow/file/FileTreeController.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
import org.springframework.web.bind.annotation.RequestMapping;
1212
import org.springframework.web.bind.annotation.RequestParam;
1313
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
1415

1516
@RestController
1617
@RequestMapping("/projects/{projectName}")
1718
public class FileTreeController {
1819

1920
private final FileTreeService fileTreeService;
21+
private final FileWatcherService fileWatcherService;
2022

21-
public FileTreeController(FileTreeService fileTreeService) {
23+
public FileTreeController(FileTreeService fileTreeService, FileWatcherService fileWatcherService) {
2224
this.fileTreeService = fileTreeService;
25+
this.fileWatcherService = fileWatcherService;
2326
}
2427

2528
@GetMapping("/tree")
@@ -60,4 +63,9 @@ public ResponseEntity<FileTreeNode> createFolder(@PathVariable String projectNam
6063
FileTreeNode node = fileTreeService.createFolder(projectName, dto.path());
6164
return ResponseEntity.status(HttpStatus.CREATED.value()).body(node);
6265
}
66+
67+
@GetMapping("/watch")
68+
public SseEmitter watchProject(@PathVariable String projectName) {
69+
return fileWatcherService.subscribeToProject(projectName);
70+
}
6371
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package org.frankframework.flow.file;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import jakarta.annotation.PreDestroy;
5+
import java.io.IOException;
6+
import java.nio.file.ClosedWatchServiceException;
7+
import java.nio.file.FileSystems;
8+
import java.nio.file.FileVisitResult;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.nio.file.SimpleFileVisitor;
12+
import java.nio.file.StandardWatchEventKinds;
13+
import java.nio.file.WatchEvent;
14+
import java.nio.file.WatchKey;
15+
import java.nio.file.WatchService;
16+
import java.nio.file.attribute.BasicFileAttributes;
17+
import java.util.Map;
18+
import java.util.Set;
19+
import java.util.concurrent.ConcurrentHashMap;
20+
import java.util.concurrent.Executors;
21+
import java.util.concurrent.ScheduledExecutorService;
22+
import java.util.concurrent.ScheduledFuture;
23+
import java.util.concurrent.TimeUnit;
24+
import lombok.extern.log4j.Log4j2;
25+
import org.frankframework.flow.filesystem.FileSystemStorage;
26+
import org.frankframework.flow.project.ConfigurationProject;
27+
import org.frankframework.flow.project.ConfigurationProjectService;
28+
import org.frankframework.flow.sse.SseChannelService;
29+
import org.springframework.stereotype.Service;
30+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
31+
32+
@Log4j2
33+
@Service
34+
public class FileWatcherService {
35+
36+
private static final long DEBOUNCE_DELAY_MS = 150;
37+
private static final Set<String> IGNORED_DIRECTORIES = Set.of(".git", "target", "node_modules");
38+
39+
private final FileSystemStorage fileSystemStorage;
40+
private final FileTreeService fileTreeService;
41+
private final ConfigurationProjectService configurationProjectService;
42+
private final SseChannelService sseChannelService;
43+
44+
private WatchService watchService;
45+
46+
private final Map<WatchKey, String> watchKeyChannels = new ConcurrentHashMap<>();
47+
private final Map<String, Runnable> channelCallbacks = new ConcurrentHashMap<>();
48+
private final Map<String, ScheduledFuture<?>> pendingBroadcasts = new ConcurrentHashMap<>();
49+
50+
private final ScheduledExecutorService debounceExecutor = Executors.newSingleThreadScheduledExecutor(
51+
Thread.ofVirtual().name("file-watcher-debounce", 0).factory()
52+
);
53+
54+
public FileWatcherService(
55+
FileSystemStorage fileSystemStorage,
56+
FileTreeService fileTreeService,
57+
ConfigurationProjectService configurationProjectService,
58+
SseChannelService sseChannelService
59+
) {
60+
this.fileSystemStorage = fileSystemStorage;
61+
this.fileTreeService = fileTreeService;
62+
this.configurationProjectService = configurationProjectService;
63+
this.sseChannelService = sseChannelService;
64+
}
65+
66+
@PostConstruct
67+
public void start() {
68+
if (!fileSystemStorage.isLocalEnvironment()) {
69+
return;
70+
}
71+
72+
try {
73+
watchService = FileSystems.getDefault().newWatchService();
74+
Thread.ofVirtual().name("file-watcher").start(this::watchLoop);
75+
log.info("File watcher service started");
76+
} catch (IOException exception) {
77+
log.error("Failed to start file watcher", exception);
78+
}
79+
}
80+
81+
@PreDestroy
82+
public void stop() {
83+
debounceExecutor.shutdownNow();
84+
if (watchService != null) {
85+
try {
86+
watchService.close();
87+
} catch (IOException exception) {
88+
log.warn("Failed to close watch service", exception);
89+
}
90+
}
91+
}
92+
93+
public SseEmitter subscribeToProject(String projectName) {
94+
if (watchService == null) {
95+
return sseChannelService.subscribe(projectName);
96+
}
97+
try {
98+
ConfigurationProject project = configurationProjectService.getProject(projectName);
99+
Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath());
100+
String channelId = projectPath.toString();
101+
channelCallbacks.put(channelId, () -> fileTreeService.invalidateTreeCache(projectName));
102+
registerRecursively(projectPath, channelId);
103+
104+
return sseChannelService.subscribe(channelId);
105+
} catch (Exception exception) {
106+
log.warn("Failed to register project for watching: {}", projectName, exception);
107+
return sseChannelService.subscribe(projectName);
108+
}
109+
}
110+
111+
public SseEmitter subscribeToPath(Path absolutePath) throws IOException {
112+
String channelId = absolutePath.toString();
113+
if (watchService != null && Files.isDirectory(absolutePath)) {
114+
WatchKey key = absolutePath.register(
115+
watchService,
116+
StandardWatchEventKinds.ENTRY_CREATE,
117+
StandardWatchEventKinds.ENTRY_DELETE,
118+
StandardWatchEventKinds.ENTRY_MODIFY
119+
);
120+
watchKeyChannels.put(key, channelId);
121+
}
122+
return sseChannelService.subscribe(channelId);
123+
}
124+
125+
private void registerRecursively(Path dir, String channelId) throws IOException {
126+
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
127+
128+
@Override
129+
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException {
130+
String name = directory.getFileName() != null ? directory.getFileName().toString() : "";
131+
if (IGNORED_DIRECTORIES.contains(name)) {
132+
return FileVisitResult.SKIP_SUBTREE;
133+
}
134+
WatchKey key = directory.register(
135+
watchService,
136+
StandardWatchEventKinds.ENTRY_CREATE,
137+
StandardWatchEventKinds.ENTRY_DELETE,
138+
StandardWatchEventKinds.ENTRY_MODIFY
139+
);
140+
watchKeyChannels.put(key, channelId);
141+
return FileVisitResult.CONTINUE;
142+
}
143+
});
144+
}
145+
146+
private void watchLoop() {
147+
while (!Thread.currentThread().isInterrupted()) {
148+
WatchKey key = takeNextKey();
149+
if (key == null) {
150+
break;
151+
}
152+
153+
String channelId = watchKeyChannels.get(key);
154+
if (channelId != null) {
155+
registerNewSubdirectories(key, channelId);
156+
scheduleBroadcast(channelId);
157+
}
158+
159+
if (!key.reset()) {
160+
watchKeyChannels.remove(key);
161+
}
162+
}
163+
}
164+
165+
private WatchKey takeNextKey() {
166+
try {
167+
return watchService.take();
168+
} catch (InterruptedException _) {
169+
Thread.currentThread().interrupt();
170+
return null;
171+
} catch (ClosedWatchServiceException _) {
172+
return null;
173+
}
174+
}
175+
176+
private void registerNewSubdirectories(WatchKey key, String channelId) {
177+
Path watchedDir = (Path) key.watchable();
178+
for (WatchEvent<?> event : key.pollEvents()) {
179+
if (event.kind() != StandardWatchEventKinds.ENTRY_CREATE) {
180+
continue;
181+
}
182+
183+
Path created = watchedDir.resolve(((WatchEvent<Path>) event).context());
184+
if (Files.isDirectory(created)) {
185+
try {
186+
registerRecursively(created, channelId);
187+
} catch (IOException _) {
188+
log.warn("Failed to register new directory: {}", created);
189+
}
190+
}
191+
}
192+
}
193+
194+
private void scheduleBroadcast(String channelId) {
195+
ScheduledFuture<?> existing = pendingBroadcasts.remove(channelId);
196+
if (existing != null) {
197+
existing.cancel(false);
198+
}
199+
pendingBroadcasts.put(channelId, debounceExecutor.schedule(() -> {
200+
Runnable callback = channelCallbacks.get(channelId);
201+
if (callback != null) {
202+
callback.run();
203+
}
204+
205+
sseChannelService.broadcast(channelId, SseEmitter.event().name("file-change").data("changed"));
206+
pendingBroadcasts.remove(channelId);
207+
}, DEBOUNCE_DELAY_MS, TimeUnit.MILLISECONDS));
208+
}
209+
}

src/main/java/org/frankframework/flow/filesystem/FilesystemController.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22

33
import java.io.IOException;
44
import java.nio.file.AccessDeniedException;
5+
import org.frankframework.flow.file.FileWatcherService;
56
import org.springframework.http.HttpStatus;
67
import org.springframework.http.ResponseEntity;
78
import org.springframework.web.bind.annotation.GetMapping;
89
import org.springframework.web.bind.annotation.PostMapping;
910
import org.springframework.web.bind.annotation.RequestMapping;
1011
import org.springframework.web.bind.annotation.RequestParam;
1112
import org.springframework.web.bind.annotation.RestController;
13+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
1214

1315
@RestController
1416
@RequestMapping("/filesystem")
1517
public class FilesystemController {
1618

1719
private final FileSystemStorage fileSystemStorage;
20+
private final FileWatcherService fileWatcherService;
1821

19-
public FilesystemController(FileSystemStorage fileSystemStorage) {
22+
public FilesystemController(FileSystemStorage fileSystemStorage, FileWatcherService fileWatcherService) {
2023
this.fileSystemStorage = fileSystemStorage;
24+
this.fileWatcherService = fileWatcherService;
2125
}
2226

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

37+
@GetMapping("/watch")
38+
public SseEmitter watch(@RequestParam String path) throws IOException {
39+
return fileWatcherService.subscribeToPath(fileSystemStorage.toAbsolutePath(path));
40+
}
41+
3342
@PostMapping("/mkdir")
3443
public ResponseEntity<Void> mkdir(@RequestParam String path) throws IOException {
3544
try {

0 commit comments

Comments
 (0)