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
53 changes: 29 additions & 24 deletions src/crates/core/src/infrastructure/filesystem/file_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,33 +167,38 @@ impl FileWatcher {
let config = self.config.clone();
let watched_paths = self.watched_paths.clone();

tokio::spawn(async move {
let mut last_flush = std::time::Instant::now();

while let Ok(event) = rx.recv() {
match event {
Ok(event) => {
if Self::should_ignore_event(&event, &watched_paths).await {
continue;
}

if let Some(file_event) = Self::convert_event(&event) {
{
let mut buffer = lock_event_buffer(&event_buffer);
buffer.push(file_event);
}

let now = std::time::Instant::now();
if now.duration_since(last_flush).as_millis() as u64
>= config.debounce_interval_ms
{
Self::flush_events_static(&event_buffer, &emitter_arc).await;
last_flush = now;
// Run on a dedicated blocking thread to avoid starving the async runtime.
// True debounce: accumulate events, then flush once the stream goes quiet for
// `debounce_interval_ms`. A 50 ms poll interval keeps latency low even for
// single-event bursts (e.g. one `fs::write` from an agentic tool).
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
let debounce = std::time::Duration::from_millis(config.debounce_interval_ms);
let poll = std::time::Duration::from_millis(50);
let mut last_event_time: Option<std::time::Instant> = None;

loop {
match rx.recv_timeout(poll) {
Ok(Ok(event)) => {
let ignore =
rt.block_on(Self::should_ignore_event(&event, &watched_paths));
if !ignore {
if let Some(file_event) = Self::convert_event(&event) {
lock_event_buffer(&event_buffer).push(file_event);
last_event_time = Some(std::time::Instant::now());
}
}
}
Err(e) => {
eprintln!("Watch error: {:?}", e);
Ok(Err(e)) => eprintln!("Watch error: {:?}", e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}

// Flush only after events have been quiet for the debounce window.
if let Some(t) = last_event_time {
if t.elapsed() >= debounce {
rt.block_on(Self::flush_events_static(&event_buffer, &emitter_arc));
last_event_time = None;
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/tools/file-system/services/FileSystemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ class FileSystemService implements IFileSystemService {
let unlisten: UnlistenFn | null = null;
let isActive = true;

// Normalize separators and trailing slash for robust cross-platform comparison.
// Case is preserved intentionally: paths are case-sensitive.
const normalizeForCompare = (p: string) =>
p.replace(/\\/g, '/').replace(/\/+$/, '');

const normalizedRoot = normalizeForCompare(rootPath);

const initWatcher = async () => {
try {
unlisten = await listen<FileWatchEvent[]>('file-system-changed', (event) => {
Expand All @@ -80,7 +87,8 @@ class FileSystemService implements IFileSystemService {
const events = event.payload;

events.forEach((fileEvent) => {
if (!fileEvent.path.startsWith(rootPath)) {
const normalizedEventPath = normalizeForCompare(fileEvent.path);
if (!normalizedEventPath.startsWith(normalizedRoot)) {
return;
}

Expand Down