Skip to content

Commit aefe3e7

Browse files
committed
Fix file watching
1 parent 848f68f commit aefe3e7

5 files changed

Lines changed: 95 additions & 60 deletions

File tree

docs/adr/010-migrate-to-bincode2.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
## Context
66

7-
The original `bincode` crate became unmaintained in late 2024/early 2025 due to a doxxing and harassment incident. The maintainer ceased all development and published v3.0.0 as a "tombstone" release that intentionally fails to compile.
7+
The original `bincode` crate became unmaintained in late 2024/early 2025 due to a doxxing and harassment incident. The
8+
maintainer ceased all development and published v3.0.0 as a "tombstone" release that intentionally fails to compile.
89

910
## Decision
1011

1112
Migrated to `bincode2` v2, a maintained fork by Pravega, which provides:
13+
1214
- Drop-in replacement with minimal code changes
1315
- Ongoing maintenance and security updates
1416
- Compatible API with bincode v1
@@ -25,8 +27,8 @@ Migrated to `bincode2` v2, a maintained fork by Pravega, which provides:
2527

2628
1. **Cargo.toml**: Changed `bincode = "1"` to `bincode2 = "2"`
2729
2. **src-tauri/src/font_metrics/mod.rs**: Updated two function calls:
28-
- `bincode::deserialize()``bincode2::deserialize()`
29-
- `bincode::serialize()``bincode2::serialize()`
30+
- `bincode::deserialize()``bincode2::deserialize()`
31+
- `bincode::serialize()``bincode2::serialize()`
3032
3. **docs/features/font-metrics.md**: Updated documentation to mention bincode2
3133

3234
## Impact

src-tauri/src/file_system/operations.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,23 +310,23 @@ pub fn list_directory_start(path: &Path, include_hidden: bool) -> Result<Listing
310310
all_entries.iter().filter(|e| !e.name.starts_with('.')).count()
311311
};
312312

313-
// Start watching the directory
314-
if let Err(e) = start_watching(&listing_id, path, all_entries.clone()) {
315-
eprintln!("[LISTING] Failed to start watcher: {}", e);
316-
// Continue anyway - watcher is optional enhancement
317-
}
318-
319-
// Cache the entries (no cursor needed - frontend fetches by range)
313+
// Cache the entries FIRST (watcher will read from here)
320314
if let Ok(mut cache) = LISTING_CACHE.write() {
321315
cache.insert(
322316
listing_id.clone(),
323317
CachedListing {
324318
path: path.to_path_buf(),
325-
entries: all_entries.clone(), // Clone to allow reuse below
319+
entries: all_entries.clone(),
326320
},
327321
);
328322
}
329323

324+
// Start watching the directory (reads initial state from cache)
325+
if let Err(e) = start_watching(&listing_id, path) {
326+
eprintln!("[LISTING] Failed to start watcher: {}", e);
327+
// Continue anyway - watcher is optional enhancement
328+
}
329+
330330
// Calculate max filename width if font metrics are available
331331
let max_filename_width = {
332332
let font_id = "system-400-12"; // Default font for now
@@ -461,6 +461,27 @@ pub fn list_directory_end(listing_id: &str) {
461461
}
462462
}
463463

464+
// ============================================================================
465+
// Internal cache accessors for file watcher
466+
// ============================================================================
467+
468+
/// Gets entries and path from the listing cache (for watcher diff computation).
469+
/// Returns None if listing not found.
470+
pub(super) fn get_listing_entries(listing_id: &str) -> Option<(std::path::PathBuf, Vec<FileEntry>)> {
471+
let cache = LISTING_CACHE.read().ok()?;
472+
let listing = cache.get(listing_id)?;
473+
Some((listing.path.clone(), listing.entries.clone()))
474+
}
475+
476+
/// Updates the entries in the listing cache (after watcher detects changes).
477+
pub(super) fn update_listing_entries(listing_id: &str, entries: Vec<FileEntry>) {
478+
if let Ok(mut cache) = LISTING_CACHE.write()
479+
&& let Some(listing) = cache.get_mut(listing_id)
480+
{
481+
listing.entries = entries;
482+
}
483+
}
484+
464485
// ============================================================================
465486
// Two-phase metadata loading: Fast core data, then extended metadata
466487
// ============================================================================

src-tauri/src/file_system/watcher.rs

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
//! File system watcher with debouncing and diff computation.
22
//!
33
//! Watches directories for changes, computes diffs, and emits events to frontend.
4+
//! Uses the unified LISTING_CACHE from operations.rs (no duplicate cache).
45
56
use notify_debouncer_full::{
67
DebounceEventResult, Debouncer, RecommendedCache, new_debouncer,
78
notify::{RecommendedWatcher, RecursiveMode},
89
};
910
use serde::{Deserialize, Serialize};
1011
use std::collections::HashMap;
11-
use std::path::{Path, PathBuf};
12+
use std::path::Path;
1213
use std::sync::{LazyLock, RwLock};
1314
use std::time::Duration;
1415
use tauri::{AppHandle, Emitter};
1516

16-
use super::operations::{FileEntry, list_directory_core};
17+
use super::operations::{FileEntry, get_listing_entries, list_directory_core, update_listing_entries};
1718

1819
/// Debounce duration in milliseconds
1920
const DEBOUNCE_MS: u64 = 200;
@@ -44,12 +45,11 @@ pub struct DirectoryDiff {
4445
pub changes: Vec<DiffChange>,
4546
}
4647

47-
/// State for a watched directory
48+
/// State for a watched directory.
49+
/// NOTE: No `entries` field - we use the unified LISTING_CACHE instead.
4850
struct WatchedDirectory {
49-
path: PathBuf,
50-
entries: Vec<FileEntry>,
5151
sequence: u64,
52-
#[allow(dead_code)] // Watcher must be held to keep watching
52+
#[allow(dead_code)] // Debouncer must be held to keep watching
5353
debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
5454
}
5555

@@ -76,16 +76,16 @@ pub fn init_watcher_manager(app: AppHandle) {
7676
}
7777
}
7878

79-
/// Start watching a directory for a given session.
79+
/// Start watching a directory for a given listing.
8080
///
8181
/// # Arguments
82-
/// * `session_id` - The session ID from list_directory_start
82+
/// * `listing_id` - The listing ID from list_directory_start
8383
/// * `path` - The directory path to watch
84-
/// * `initial_entries` - The initial directory entries (for diff computation)
85-
pub fn start_watching(session_id: &str, path: &Path, initial_entries: Vec<FileEntry>) -> Result<(), String> {
86-
let session_id_owned = session_id.to_string();
87-
let path_owned = path.to_path_buf();
88-
let session_for_closure = session_id_owned.clone();
84+
///
85+
/// Note: Initial entries are read from LISTING_CACHE when needed.
86+
pub fn start_watching(listing_id: &str, path: &Path) -> Result<(), String> {
87+
let listing_id_owned = listing_id.to_string();
88+
let listing_for_closure = listing_id_owned.clone();
8989

9090
// Create the debouncer with a callback that handles changes
9191
let mut debouncer = new_debouncer(
@@ -94,7 +94,7 @@ pub fn start_watching(session_id: &str, path: &Path, initial_entries: Vec<FileEn
9494
move |result: DebounceEventResult| {
9595
if let Ok(_events) = result {
9696
// Events occurred - re-read directory and compute diff
97-
handle_directory_change(&session_for_closure);
97+
handle_directory_change(&listing_for_closure);
9898
}
9999
},
100100
)
@@ -105,45 +105,39 @@ pub fn start_watching(session_id: &str, path: &Path, initial_entries: Vec<FileEn
105105
.watch(path, RecursiveMode::NonRecursive)
106106
.map_err(|e| format!("Failed to watch path: {}", e))?;
107107

108-
// Store in manager
108+
// Store in manager (no entries - we use LISTING_CACHE)
109109
let mut manager = WATCHER_MANAGER.write().map_err(|_| "Failed to acquire watcher lock")?;
110110

111-
manager.watches.insert(
112-
session_id_owned.clone(),
113-
WatchedDirectory {
114-
path: path_owned,
115-
entries: initial_entries,
116-
sequence: 0,
117-
debouncer,
118-
},
119-
);
111+
manager
112+
.watches
113+
.insert(listing_id_owned, WatchedDirectory { sequence: 0, debouncer });
120114

121115
Ok(())
122116
}
123117

124-
/// Stop watching a directory for a given session.
125-
pub fn stop_watching(session_id: &str) {
118+
/// Stop watching a directory for a given listing.
119+
pub fn stop_watching(listing_id: &str) {
126120
if let Ok(mut manager) = WATCHER_MANAGER.write() {
127121
// Dropping the WatchedDirectory will drop the debouncer
128-
manager.watches.remove(session_id);
122+
manager.watches.remove(listing_id);
129123
}
130124
}
131125

132126
/// Handle a directory change event.
133-
/// Re-reads the directory, computes diff, and emits event.
134-
fn handle_directory_change(session_id: &str) {
135-
let (path, old_entries, app_handle) = {
127+
/// Re-reads the directory, computes diff, updates LISTING_CACHE, and emits event.
128+
fn handle_directory_change(listing_id: &str) {
129+
// Get old entries and path from the unified LISTING_CACHE
130+
let Some((path, old_entries)) = get_listing_entries(listing_id) else {
131+
return; // Listing no longer exists
132+
};
133+
134+
// Get app handle for emitting events
135+
let app_handle = {
136136
let manager = match WATCHER_MANAGER.read() {
137137
Ok(m) => m,
138138
Err(_) => return,
139139
};
140-
141-
let watch = match manager.watches.get(session_id) {
142-
Some(w) => w,
143-
None => return,
144-
};
145-
146-
(watch.path.clone(), watch.entries.clone(), manager.app_handle.clone())
140+
manager.app_handle.clone()
147141
};
148142

149143
// Re-read the directory using core metadata (extended metadata not needed for diffs)
@@ -162,27 +156,29 @@ fn handle_directory_change(session_id: &str) {
162156
return; // No actual changes
163157
}
164158

165-
// Update stored entries and increment sequence
159+
// Update the unified LISTING_CACHE with new entries
160+
update_listing_entries(listing_id, new_entries);
161+
162+
// Increment sequence and get current value
166163
let sequence = {
167164
let mut manager = match WATCHER_MANAGER.write() {
168165
Ok(m) => m,
169166
Err(_) => return,
170167
};
171168

172-
let watch = match manager.watches.get_mut(session_id) {
169+
let watch = match manager.watches.get_mut(listing_id) {
173170
Some(w) => w,
174171
None => return,
175172
};
176173

177-
watch.entries = new_entries;
178174
watch.sequence += 1;
179175
watch.sequence
180176
};
181177

182178
// Emit event to frontend
183179
if let Some(app) = app_handle {
184180
let diff = DirectoryDiff {
185-
listing_id: session_id.to_string(),
181+
listing_id: listing_id.to_string(),
186182
sequence,
187183
changes,
188184
};

src/lib/file-explorer/BriefList.svelte

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,21 +317,29 @@
317317
// Track previous values to detect actual changes
318318
let prevListingId = ''
319319
let prevIncludeHidden = false
320+
let prevTotalCount = 0
320321
321-
// Single effect: fetch when ready, reset cache only when listingId/includeHidden actually changes
322+
// Single effect: fetch when ready, reset cache only when listingId/includeHidden/totalCount actually changes
322323
$effect(() => {
323324
// Read reactive dependencies
324325
const currentListingId = listingId
325326
const currentIncludeHidden = includeHidden
327+
const currentTotalCount = totalCount
326328
if (!currentListingId || containerHeight <= 0) return
327329
328-
// Check if listingId or includeHidden actually changed
329-
if (currentListingId !== prevListingId || currentIncludeHidden !== prevIncludeHidden) {
330-
// Reset cache for new listing or filter change
330+
// Check if listingId, includeHidden, or totalCount actually changed
331+
// totalCount changes when files are added/removed by the file watcher
332+
if (
333+
currentListingId !== prevListingId ||
334+
currentIncludeHidden !== prevIncludeHidden ||
335+
currentTotalCount !== prevTotalCount
336+
) {
337+
// Reset cache for new listing, filter change, or file count change
331338
cachedEntries = []
332339
cachedRange = { start: 0, end: 0 }
333340
prevListingId = currentListingId
334341
prevIncludeHidden = currentIncludeHidden
342+
prevTotalCount = currentTotalCount
335343
}
336344
337345
void fetchVisibleRange()

src/lib/file-explorer/FullList.svelte

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,21 +274,29 @@
274274
// Track previous values to detect actual changes
275275
let prevListingId = ''
276276
let prevIncludeHidden = false
277+
let prevTotalCount = 0
277278
278-
// Single effect: fetch when ready, reset cache only when listingId/includeHidden actually changes
279+
// Single effect: fetch when ready, reset cache only when listingId/includeHidden/totalCount actually changes
279280
$effect(() => {
280281
// Read reactive dependencies
281282
const currentListingId = listingId
282283
const currentIncludeHidden = includeHidden
284+
const currentTotalCount = totalCount
283285
if (!currentListingId || containerHeight <= 0) return
284286
285-
// Check if listingId or includeHidden actually changed
286-
if (currentListingId !== prevListingId || currentIncludeHidden !== prevIncludeHidden) {
287-
// Reset cache for new listing or filter change
287+
// Check if listingId, includeHidden, or totalCount actually changed
288+
// totalCount changes when files are added/removed by the file watcher
289+
if (
290+
currentListingId !== prevListingId ||
291+
currentIncludeHidden !== prevIncludeHidden ||
292+
currentTotalCount !== prevTotalCount
293+
) {
294+
// Reset cache for new listing, filter change, or file count change
288295
cachedEntries = []
289296
cachedRange = { start: 0, end: 0 }
290297
prevListingId = currentListingId
291298
prevIncludeHidden = currentIncludeHidden
299+
prevTotalCount = currentTotalCount
292300
}
293301
294302
void fetchVisibleRange()

0 commit comments

Comments
 (0)