Skip to content

Commit cf12372

Browse files
committed
Add live file watching with incremental diffs
Backend: - Add notify-debouncer-full for 200 ms debounced file watching - Compute diffs on change, emit directory-diff events - Session stays active until navigation (not just during load) Frontend: - Listen for directory-diff events per session - Apply diffs incrementally with cursor preservation - Fallback to full reload on sequence gaps Tests: - 19 unit tests for applyDiff covering multi-file diffs and edge cases - Backend compute_diff tests
1 parent 7d977a1 commit cf12372

12 files changed

Lines changed: 825 additions & 68 deletions

File tree

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- [x] Improve performance
1414
- [x] Display file info below each panel for the file under the cursor
1515
- [x] Add context menu for files and folders
16-
- [ ] Add file watching to auto-update changes. It should be as close to immediate as possible
16+
- [x] Add file watching to auto-update changes. It should be as close to immediate as possible
1717
- [ ] Implement proper Full view (with fixed columns)
1818
- [ ] Add Brief view with view switching option
1919
- [ ] Add different sorting options

src-tauri/Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ base64 = "0.22.1"
3434
rayon = "1.11.0"
3535
uuid = { version = "1.19.0", features = ["v4"] }
3636
tauri-plugin-clipboard-manager = "2"
37+
notify-debouncer-full = "0.6.0"
3738

3839
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
3940
tauri-plugin-window-state = "2"

src-tauri/src/file_system/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ pub use mock_provider::MockFileSystemProvider;
1717
pub use operations::{
1818
ChunkNextResult, SessionStartResult, list_directory_end, list_directory_next, list_directory_start,
1919
};
20-
// Re-export FileEntry for internal submodules (provider, mock_provider, real_provider)
20+
// FileEntry re-exported for test modules (provider, mock_provider, real_provider, mock_provider_test)
2121
#[cfg(test)]
2222
pub(crate) use operations::FileEntry;
2323
#[cfg(test)]
2424
pub use provider::FileSystemProvider;
25+
// Watcher management - init_watcher_manager must be called from lib.rs
26+
pub use watcher::init_watcher_manager;
2527

2628
#[cfg(test)]
2729
mod operations_test;

src-tauri/src/file_system/operations.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ use std::os::unix::fs::{MetadataExt, PermissionsExt};
99
use std::path::Path;
1010
use std::sync::LazyLock;
1111
use std::sync::RwLock;
12-
use std::time::Instant;
1312
use uuid::Uuid;
1413
use uzers::{get_group_by_gid, get_user_by_uid};
1514

15+
use super::watcher::{start_watching, stop_watching};
16+
1617
/// Cache for uid→username and gid→groupname resolution.
1718
static OWNER_CACHE: LazyLock<RwLock<HashMap<u32, String>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
1819
static GROUP_CACHE: LazyLock<RwLock<HashMap<u32, String>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
@@ -24,9 +25,10 @@ static SESSION_CACHE: LazyLock<RwLock<HashMap<String, CachedDirectory>>> =
2425

2526
/// Cached directory entries for cursor-based pagination.
2627
struct CachedDirectory {
28+
path: std::path::PathBuf,
2729
entries: Vec<FileEntry>,
2830
cursor: usize,
29-
created_at: Instant,
31+
// No created_at - sessions are eternal until explicitly ended
3032
}
3133

3234
/// Resolves a uid to a username, with caching.
@@ -303,19 +305,23 @@ pub fn list_directory_start(path: &Path, chunk_size: usize) -> Result<SessionSta
303305
let first_chunk: Vec<FileEntry> = all_entries.iter().take(chunk_size).cloned().collect();
304306
let has_more = total_count > chunk_size;
305307

306-
// Cache the entries with cursor position
308+
// Start watching the directory immediately
309+
// Clone entries for the watcher (it needs its own copy for diff computation)
310+
if let Err(e) = start_watching(&session_id, path, all_entries.clone()) {
311+
eprintln!("[SESSION] Failed to start watcher: {}", e);
312+
// Continue anyway - watcher is optional enhancement
313+
}
314+
315+
// Cache the entries with cursor position (no expiry)
307316
if let Ok(mut cache) = SESSION_CACHE.write() {
308317
cache.insert(
309318
session_id.clone(),
310319
CachedDirectory {
320+
path: path.to_path_buf(),
311321
entries: all_entries,
312322
cursor: chunk_size.min(total_count),
313-
created_at: Instant::now(),
314323
},
315324
);
316-
317-
// Clean up old sessions (older than 60 seconds)
318-
cache.retain(|_, v| v.created_at.elapsed().as_secs() < 60);
319325
}
320326

321327
Ok(SessionStartResult {
@@ -357,6 +363,10 @@ pub fn list_directory_next(session_id: &str, chunk_size: usize) -> Result<ChunkN
357363
/// # Arguments
358364
/// * `session_id` - The session ID to clean up
359365
pub fn list_directory_end(session_id: &str) {
366+
// Stop the file watcher
367+
stop_watching(session_id);
368+
369+
// Remove from session cache
360370
if let Ok(mut cache) = SESSION_CACHE.write() {
361371
cache.remove(session_id);
362372
}

0 commit comments

Comments
 (0)