Skip to content

fix(project-explorer): Add proper debouncing and UI notification for file watcher events #8471

@DenisCDev

Description

@DenisCDev

Problem

Project Explorer doesn't update when files are created/deleted/modified externally.

Related issues: #7900, #7954, #8295

Root Cause Analysis

Components Identified (via stack traces and issues)

FileTreeEntryState          -> Maintains file tree in memory
RepositoryMetadataModel     -> Processes events via handle_watcher_event()
File Watcher (notify crate) -> Watches filesystem changes

Problems Identified

Problem Evidence Impact
UI not receiving events Deleted files remain visible Main bug
No debouncing Memory spikes during rapid changes Performance
Clone on main thread 72GB clone of FileTreeEntryState Freeze/crash
Unbounded growth FileTreeEntryState grows infinitely Memory leak

Proposed Solution

Corrected Flow

Filesystem Change
       ↓
DebouncedFileWatcher (aggregates events 100-300ms)
       ↓
handle_watcher_event() [BACKGROUND THREAD]
       ↓
FileTreeEntryState.apply_event() [in-place, no clone]
       ↓
broadcast::send(FileTreeUpdate)
       ↓
ProjectExplorerPane.invalidate_paths() [UI THREAD]
       ↓
UI updates automatically

Key Changes

1. Implement Event Debouncing (repo_metadata/watcher.rs)

pub struct DebouncedFileWatcher {
    watcher: RecommendedWatcher,
    pending_events: HashMap<PathBuf, FileChangeEvent>,
    debounce_duration: Duration,
    last_flush: Instant,
}

impl DebouncedFileWatcher {
    /// Coalesce events: DELETE + CREATE = MODIFY
    fn coalesce_events(&self, events: Vec<FileChangeEvent>) -> Vec<FileChangeEvent> {
        let mut by_path: HashMap<PathBuf, FileChangeEvent> = HashMap::new();
        for event in events {
            by_path.entry(event.path.clone())
                .and_modify(|existing| {
                    if existing.kind == EventKind::Remove && event.kind == EventKind::Create {
                        existing.kind = EventKind::Modify;
                    } else {
                        *existing = event.clone();
                    }
                })
                .or_insert(event);
        }
        by_path.into_values().collect()
    }
}

2. Background Processing with UI Notification (repo_metadata/model.rs)

pub struct RepositoryMetadataModel {
    file_tree: Arc<RwLock<FileTreeEntryState>>,
    ui_notifier: broadcast::Sender<FileTreeUpdate>,
}

impl RepositoryMetadataModel {
    pub fn handle_watcher_event(&self, event: notify::Event) {
        let file_tree = Arc::clone(&self.file_tree);
        let ui_notifier = self.ui_notifier.clone();

        // Process in background thread (NOT on main thread)
        tokio::spawn(async move {
            {
                let mut tree = file_tree.write().await;
                tree.apply_event(&event);
            }

            // CRITICAL: Notify UI of changes
            let _ = ui_notifier.send(FileTreeUpdate {
                paths: event.paths.clone(),
                kind: event.kind,
            });
        });
    }
}

3. Bounded LRU Cache (repo_metadata/file_tree_store.rs)

pub struct FileTreeEntryState {
    entries: LruCache<PathBuf, FileEntry>,  // Bounded cache
    expanded_dirs: HashSet<PathBuf>,
    max_entries: usize,
}

impl FileTreeEntryState {
    /// In-place update without cloning
    pub fn apply_event(&mut self, event: &notify::Event) {
        match event.kind {
            EventKind::Create(_) => { /* add entries */ }
            EventKind::Remove(_) => { /* remove entries */ }
            EventKind::Modify(_) => { /* refresh metadata */ }
            _ => {}
        }
        // Enforce capacity limit
        while self.entries.len() > self.max_entries {
            self.entries.pop_lru();
        }
    }
}

4. Hybrid Watcher with Polling Fallback

pub struct HybridFileWatcher {
    native_watcher: Option<RecommendedWatcher>,
    poll_watcher: PollWatcher,
    use_polling_for: HashSet<PathBuf>,
}

impl HybridFileWatcher {
    pub fn watch(&mut self, path: &Path) -> Result<(), Error> {
        // Try native watcher first, fallback to polling for NFS/WSL
        if let Some(ref mut native) = self.native_watcher {
            if native.watch(path, RecursiveMode::Recursive).is_ok() {
                return Ok(());
            }
        }
        self.poll_watcher.watch(path, RecursiveMode::Recursive)
    }
}

5. UI Listener (ui/project_explorer.rs)

impl ProjectExplorerPane {
    pub fn setup_file_tree_listener(&mut self, rx: broadcast::Receiver<FileTreeUpdate>) {
        let tree_view = self.tree_view.clone();
        tokio::spawn(async move {
            let mut rx = rx;
            while let Ok(update) = rx.recv().await {
                tree_view.invalidate_paths(&update.paths);
                tree_view.request_redraw();
            }
        });
    }
}

Reference Implementation

A complete working implementation is available at:
https://github.com/DenisCDev/renata-geral/tree/main/warp-file-watcher-fix

Testing Checklist

  • Create file via terminal → appears in explorer
  • Delete file via terminal → disappears from explorer
  • Modify file externally → shows updated timestamp
  • Rapid file operations → no freeze, updates batched
  • Large repo (>100k files) → memory stays bounded
  • NFS/WSL paths → polling fallback works

Files to Modify

File Change
repo_metadata/watcher.rs Add DebouncedFileWatcher, HybridFileWatcher
repo_metadata/model.rs Process events in background, notify UI
repo_metadata/file_tree_store.rs Use LRU cache, apply_event in-place
ui/project_explorer.rs Listen to broadcast channel, invalidate paths

Metadata

Metadata

Assignees

No one assigned

    Labels

    triagedIssue has received an initial automated triage pass.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions