Enhance indexing and change detection mechanisms

- Refactored the `IndexPersistence` implementation to improve error handling and logging when querying directory paths.
- Updated the change detection logic to always load existing entries during reindexing, ensuring accurate detection of moves, modifications, and deletions.
- Introduced a new test suite for folder rename operations, covering both persistent and ephemeral indexing scenarios, validating UUID/inode preservation and parent-child relationship integrity.
- Enhanced the `IndexingHarnessBuilder` to allow disabling the filesystem watcher for specific tests, improving test flexibility.
- Added closure table integrity verification to ensure proper connections between renamed folders and their children, preventing data inconsistencies.
This commit is contained in:
Jamie Pine
2025-12-24 08:04:33 -08:00
parent 3555a11795
commit 9f5c7c5880
8 changed files with 712 additions and 176 deletions

View File

@@ -797,14 +797,28 @@ impl<'a> IndexPersistence for DatabaseAdapterForJob<'a> {
};
let indexing_path_str = indexing_path.to_string_lossy().to_string();
let indexing_path_entry_id = if let Ok(Some(dir_record)) = directory_paths::Entity::find()
let indexing_path_entry_id = match directory_paths::Entity::find()
.filter(directory_paths::Column::Path.eq(&indexing_path_str))
.one(self.ctx.library_db())
.await
{
dir_record.entry_id
} else {
location_root_entry_id
Ok(Some(dir_record)) => dir_record.entry_id,
Ok(None) => {
// Path not found in database - this is either a new directory or a moved one.
// Return empty to let inode-based move detection handle it, rather than
// incorrectly loading entries from the entire location root.
self.ctx.log(format!(
"Indexing path not found in database: {}, treating as new (move detection via inode)",
indexing_path_str
));
return Ok(HashMap::new());
}
Err(e) => {
return Err(JobError::execution(format!(
"Failed to query directory_paths: {}",
e
)));
}
};
let descendant_ids = entry_closure::Entity::find()

View File

@@ -143,16 +143,18 @@ pub async fn run_processing_phase(
.await;
let mut change_detector = ChangeDetector::new();
if !state.existing_entries.is_empty() || mode != IndexMode::Shallow {
ctx.log("Loading existing entries for change detection...");
change_detector
.load_existing_entries(ctx, location_id_i32, location_root_path)
.await?;
ctx.log(format!(
"Loaded {} existing entries",
change_detector.entry_count()
));
}
// Always load existing entries for change detection during reindexing.
// This is required to detect moves, modifications, and deletions regardless
// of IndexMode. The mode only affects what metadata gets extracted, not
// whether we detect changes.
ctx.log("Loading existing entries for change detection...");
change_detector
.load_existing_entries(ctx, location_id_i32, location_root_path)
.await?;
ctx.log(format!(
"Loaded {} existing entries",
change_detector.entry_count()
));
// Sort all discovered entries by depth (parents before children) to ensure parent entries
// exist in the database before we try to create children with parent_id foreign keys.
@@ -495,7 +497,15 @@ pub async fn run_processing_phase(
// Handle deleted entries
if change_detector.entry_count() > 0 {
ctx.log("Checking for deleted entries...");
let seen_paths: std::collections::HashSet<_> = state.seen_paths.iter().cloned().collect();
// Build seen_paths, ensuring the indexing root is included. The indexing path is
// loaded as an existing entry but never "seen" during discovery (which only scans
// children). Without this, an indexer job spawned for a subdirectory would detect
// the subdirectory itself as "missing" and delete it along with all its children.
let mut seen_paths: std::collections::HashSet<_> =
state.seen_paths.iter().cloned().collect();
seen_paths.insert(location_root_path.to_path_buf());
let deleted = change_detector.find_deleted(&seen_paths);
if !deleted.is_empty() {

View File

@@ -0,0 +1,466 @@
//! Integration tests for folder rename operations
//!
//! This test suite verifies folder rename handling in four scenarios:
//! 1. Persistent + Manual Reindex (batch change detection)
//! 2. Persistent + Watcher (real-time change handling)
//! 3. Ephemeral + Manual Reindex (batch change detection)
//! 4. Ephemeral + Watcher (real-time change handling)
//!
//! Each test validates:
//! - Folder UUID/inode preservation
//! - Parent-child relationship integrity
//! - Correct event emission for UI updates
mod helpers;
use helpers::*;
use sd_core::{
domain::addressing::SdPath,
location::IndexMode,
ops::indexing::{IndexScope, IndexerJob, IndexerJobConfig},
};
use tokio::time::Duration;
// ============================================================================
// PERSISTENT INDEXING TESTS
// ============================================================================
#[tokio::test]
async fn test_persistent_folder_rename_via_reindex() -> anyhow::Result<()> {
// Tests batch change detection during manual reindex (watcher disabled)
let harness = IndexingHarnessBuilder::new("persistent_rename_reindex")
.disable_watcher()
.build()
.await?;
let test_location = harness.create_test_location("test_rename").await?;
// Create folder structure with files inside
test_location.create_dir("original_folder").await?;
test_location
.write_file("original_folder/file1.txt", "Content 1")
.await?;
test_location
.write_file("original_folder/file2.rs", "fn main() {}")
.await?;
test_location
.write_file("original_folder/nested/file3.md", "# Docs")
.await?;
// Initial indexing
let location = test_location
.index("Test Location", IndexMode::Shallow)
.await?;
// Verify initial structure
let initial_entries = location.get_all_entries().await?;
let folder_before = initial_entries
.iter()
.find(|e| e.name == "original_folder" && e.kind == 1)
.expect("Original folder should exist");
let folder_uuid = folder_before.uuid;
let folder_inode = folder_before.inode;
let folder_id = folder_before.id;
let file1_before = initial_entries
.iter()
.find(|e| e.name == "file1")
.expect("file1 should exist");
assert_eq!(
file1_before.parent_id,
Some(folder_id),
"file1 should be child of original_folder"
);
let initial_file_count = location.count_files().await?;
assert_eq!(initial_file_count, 3, "Should have 3 files initially");
// Wait for indexing to settle
tokio::time::sleep(Duration::from_millis(500)).await;
// Start collecting events BEFORE rename
let mut collector = EventCollector::new(&harness.core.events);
let collection_handle = tokio::spawn(async move {
collector.collect_events(Duration::from_secs(5)).await;
collector
});
tokio::time::sleep(Duration::from_millis(100)).await;
// Rename the folder
tracing::info!("Renaming folder from original_folder to renamed_folder");
location
.move_file("original_folder", "renamed_folder")
.await?;
// Manual reindex to detect the rename via batch change detection
location.reindex().await?;
// Wait for reindex to complete
tokio::time::sleep(Duration::from_secs(2)).await;
// Verify final structure
let final_entries = location.get_all_entries().await?;
// Folder should have new name but same UUID/inode/ID
let folder_after = final_entries
.iter()
.find(|e| e.name == "renamed_folder" && e.kind == 1)
.expect("Renamed folder should exist");
assert_eq!(
folder_after.uuid, folder_uuid,
"Folder UUID should be preserved after rename"
);
assert_eq!(
folder_after.inode, folder_inode,
"Folder inode should be preserved after rename"
);
assert_eq!(
folder_after.id, folder_id,
"Folder ID should be preserved after rename"
);
// Old folder name should not exist
let old_folder_exists = final_entries
.iter()
.any(|e| e.name == "original_folder" && e.kind == 1);
assert!(
!old_folder_exists,
"Old folder name should not exist after rename"
);
// All files should still exist with renamed folder as parent
let file1_after = final_entries
.iter()
.find(|e| e.name == "file1")
.expect("file1 should still exist");
let file2_after = final_entries
.iter()
.find(|e| e.name == "file2")
.expect("file2 should still exist");
let file3_after = final_entries
.iter()
.find(|e| e.name == "file3")
.expect("file3 should still exist");
assert_eq!(
file1_after.parent_id,
Some(folder_id),
"file1 should still be child of folder (parent_id preserved)"
);
assert_eq!(
file2_after.parent_id,
Some(folder_id),
"file2 should still be child of folder (parent_id preserved)"
);
// Verify nested folder structure is intact
let nested_folder = final_entries
.iter()
.find(|e| e.name == "nested" && e.kind == 1)
.expect("nested folder should exist");
assert_eq!(
nested_folder.parent_id,
Some(folder_id),
"nested folder should be child of renamed folder"
);
assert_eq!(
file3_after.parent_id,
Some(nested_folder.id),
"file3 should be child of nested folder"
);
// Total file count should remain the same
let final_file_count = location.count_files().await?;
assert_eq!(
final_file_count, initial_file_count,
"File count should remain the same after folder rename"
);
// CRITICAL: Verify closure table integrity
// This catches the bug where children exist but aren't connected via closure table
location.verify_closure_table_integrity().await?;
// Verify events were emitted during the operation
let collector = collection_handle.await.unwrap();
let stats = collector.analyze().await;
// Note: Move operations may not emit resource change events immediately
// The core functionality (database integrity) is what matters
let total_events = stats.resource_changed_batch.values().sum::<usize>()
+ stats.resource_changed.values().sum::<usize>();
if total_events == 0 {
tracing::warn!("No resource change events emitted during folder rename");
}
harness.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn test_persistent_folder_rename_via_watcher() -> anyhow::Result<()> {
// Tests real-time watcher change handling (no manual reindex)
let harness = IndexingHarnessBuilder::new("persistent_rename_watcher")
.build() // Watcher enabled by default
.await?;
let test_location = harness.create_test_location("test_rename").await?;
// Create folder structure
test_location.create_dir("original_folder").await?;
test_location
.write_file("original_folder/file1.txt", "Content 1")
.await?;
test_location
.write_file("original_folder/file2.rs", "fn main() {}")
.await?;
// Initial indexing
let location = test_location
.index("Test Location", IndexMode::Shallow)
.await?;
let initial_entries = location.get_all_entries().await?;
let folder_before = initial_entries
.iter()
.find(|e| e.name == "original_folder" && e.kind == 1)
.expect("Original folder should exist");
let folder_uuid = folder_before.uuid;
let folder_id = folder_before.id;
// Wait for indexing to settle
tokio::time::sleep(Duration::from_millis(500)).await;
// Start collecting events
let mut collector = EventCollector::new(&harness.core.events);
let collection_handle = tokio::spawn(async move {
collector.collect_events(Duration::from_secs(10)).await;
collector
});
tokio::time::sleep(Duration::from_millis(100)).await;
// Rename the folder - watcher should detect and handle it
tracing::info!("Renaming folder (watcher will detect)");
location
.move_file("original_folder", "renamed_folder")
.await?;
// NO manual reindex - rely on watcher to handle the change
// Wait for watcher to process the rename event
// Directories are buffered for 500ms for rename detection, then emitted on tick
tokio::time::sleep(Duration::from_secs(8)).await;
// Verify final structure
let final_entries = location.get_all_entries().await?;
// Folder should exist with new name
let folder_after = final_entries
.iter()
.find(|e| e.name == "renamed_folder" && e.kind == 1)
.expect("Renamed folder should exist");
// Watcher should preserve UUID/ID through move detection
assert_eq!(
folder_after.uuid, folder_uuid,
"Folder UUID should be preserved by watcher"
);
assert_eq!(
folder_after.id, folder_id,
"Folder ID should be preserved by watcher"
);
// Files should still exist
let file1_exists = final_entries.iter().any(|e| e.name == "file1");
let file2_exists = final_entries.iter().any(|e| e.name == "file2");
assert!(file1_exists, "file1 should still exist");
assert!(file2_exists, "file2 should still exist");
// CRITICAL: Verify closure table integrity
location.verify_closure_table_integrity().await?;
// Verify events were emitted (watcher emits ResourceChangedBatch for persistent)
let collector = collection_handle.await.unwrap();
let stats = collector.analyze().await;
let event_count = stats.resource_changed_batch.values().sum::<usize>()
+ stats.resource_changed.values().sum::<usize>();
assert!(
event_count > 0,
"Should emit resource change events from watcher"
);
harness.shutdown().await?;
Ok(())
}
// ============================================================================
// EPHEMERAL INDEXING TESTS
// ============================================================================
#[tokio::test]
async fn test_ephemeral_folder_rename_via_reindex() -> anyhow::Result<()> {
// Tests ephemeral batch change detection during manual reindex (watcher disabled)
let harness = IndexingHarnessBuilder::new("ephemeral_rename_reindex")
.disable_watcher()
.build()
.await?;
let test_root = harness.temp_path();
let original_folder = test_root.join("original_folder");
tokio::fs::create_dir_all(&original_folder).await?;
tokio::fs::write(original_folder.join("file1.txt"), "Content 1").await?;
tokio::fs::write(original_folder.join("file2.rs"), "fn main() {}").await?;
// Index in ephemeral mode
let test_root_sd = SdPath::local(test_root.clone());
let indexer_config = IndexerJobConfig::ephemeral_browse(test_root_sd, IndexScope::Recursive);
let indexer_job = IndexerJob::new(indexer_config);
tracing::info!("Initial ephemeral indexing");
let index_handle = harness.library.jobs().dispatch(indexer_job).await?;
index_handle.wait().await?;
harness
.core
.context
.ephemeral_cache()
.mark_indexing_complete(&test_root);
tokio::time::sleep(Duration::from_millis(500)).await;
// Rename the folder
let renamed_folder = test_root.join("renamed_folder");
tracing::info!("Renaming folder in filesystem");
tokio::fs::rename(&original_folder, &renamed_folder).await?;
// Manual reindex to detect the change
let reindex_config =
IndexerJobConfig::ephemeral_browse(SdPath::local(test_root.clone()), IndexScope::Recursive);
let reindex_job = IndexerJob::new(reindex_config);
let reindex_handle = harness.library.jobs().dispatch(reindex_job).await?;
reindex_handle.wait().await?;
tokio::time::sleep(Duration::from_millis(500)).await;
// Verify filesystem state
assert!(
!tokio::fs::try_exists(&original_folder)
.await
.unwrap_or(false),
"Original folder should not exist"
);
assert!(
tokio::fs::try_exists(&renamed_folder).await?,
"Renamed folder should exist"
);
assert!(
tokio::fs::try_exists(renamed_folder.join("file1.txt")).await?,
"file1.txt should exist in renamed folder"
);
assert!(
tokio::fs::try_exists(renamed_folder.join("file2.rs")).await?,
"file2.rs should exist in renamed folder"
);
harness.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn test_ephemeral_folder_rename_via_watcher() -> anyhow::Result<()> {
// Tests ephemeral real-time watcher change handling (no manual reindex)
let harness = IndexingHarnessBuilder::new("ephemeral_rename_watcher")
.build() // Watcher enabled
.await?;
let test_root = harness.temp_path();
let original_folder = test_root.join("original_folder");
tokio::fs::create_dir_all(&original_folder).await?;
tokio::fs::write(original_folder.join("file1.txt"), "Content 1").await?;
tokio::fs::write(original_folder.join("file2.rs"), "fn main() {}").await?;
// Index in ephemeral mode
let test_root_sd = SdPath::local(test_root.clone());
let indexer_config = IndexerJobConfig::ephemeral_browse(test_root_sd, IndexScope::Recursive);
let indexer_job = IndexerJob::new(indexer_config);
tracing::info!("Initial ephemeral indexing");
let index_handle = harness.library.jobs().dispatch(indexer_job).await?;
index_handle.wait().await?;
harness
.core
.context
.ephemeral_cache()
.mark_indexing_complete(&test_root);
// Register for watching
if let Some(watcher) = harness.core.context.get_fs_watcher().await {
watcher.watch_ephemeral(test_root.clone()).await?;
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Start collecting events
let mut collector = EventCollector::new(&harness.core.events);
let collection_handle = tokio::spawn(async move {
collector.collect_events(Duration::from_secs(10)).await;
collector
});
tokio::time::sleep(Duration::from_millis(100)).await;
// Rename the folder - watcher should detect it
let renamed_folder = test_root.join("renamed_folder");
tracing::info!("Renaming folder (watcher will detect)");
tokio::fs::rename(&original_folder, &renamed_folder).await?;
// NO manual reindex - wait for watcher to handle it
// Directories are buffered for 500ms for rename detection, then emitted on tick
tokio::time::sleep(Duration::from_secs(8)).await;
// Verify filesystem state
assert!(
!tokio::fs::try_exists(&original_folder)
.await
.unwrap_or(false),
"Original folder should not exist"
);
assert!(
tokio::fs::try_exists(&renamed_folder).await?,
"Renamed folder should exist"
);
assert!(
tokio::fs::try_exists(renamed_folder.join("file1.txt")).await?,
"file1.txt should exist in renamed folder"
);
assert!(
tokio::fs::try_exists(renamed_folder.join("file2.rs")).await?,
"file2.rs should exist in renamed folder"
);
// Verify file contents preserved
let file1_content = tokio::fs::read_to_string(renamed_folder.join("file1.txt")).await?;
assert_eq!(
file1_content, "Content 1",
"File content should be preserved"
);
// Verify watcher emitted events (ephemeral uses individual ResourceChanged events)
let collector = collection_handle.await.unwrap();
let stats = collector.analyze().await;
let event_count = stats.resource_changed.values().sum::<usize>();
assert!(
event_count > 0,
"Should emit ResourceChanged events from watcher, got {}",
event_count
);
harness.shutdown().await?;
Ok(())
}

View File

@@ -22,6 +22,7 @@ use uuid::Uuid;
/// Builder for creating indexing test harness
pub struct IndexingHarnessBuilder {
test_name: String,
watcher_enabled: bool,
}
impl IndexingHarnessBuilder {
@@ -29,11 +30,18 @@ impl IndexingHarnessBuilder {
pub fn new(test_name: impl Into<String>) -> Self {
Self {
test_name: test_name.into(),
watcher_enabled: true, // Enabled by default
}
}
/// Disable the filesystem watcher for this test
pub fn disable_watcher(mut self) -> Self {
self.watcher_enabled = false;
self
}
/// Build the harness
pub async fn build(mut self) -> anyhow::Result<IndexingHarness> {
pub async fn build(self) -> anyhow::Result<IndexingHarness> {
// Use home directory for proper filesystem watcher support on macOS
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root = PathBuf::from(home).join(format!(".spacedrive_test_{}", self.test_name));
@@ -48,13 +56,13 @@ impl IndexingHarnessBuilder {
// Initialize tracing
init_test_tracing(&self.test_name, &snapshot_dir)?;
// Create config with watcher ENABLED (unlike sync tests which disable it)
// Create config with configurable watcher
let mut config = TestConfigBuilder::new(test_root.clone())
.build()
.context("Failed to create test config")?;
// Enable filesystem watcher for change detection tests
config.services.fs_watcher_enabled = true;
// Set watcher state based on builder configuration
config.services.fs_watcher_enabled = self.watcher_enabled;
config.save()?;
// Initialize core
@@ -470,4 +478,86 @@ impl<'a> LocationHandle<'a> {
tracing::info!("Re-indexing completed");
Ok(())
}
/// Verify closure table integrity
///
/// This is critical for folder renames! When a folder is renamed, all children
/// must remain properly connected via the closure table. Without this, queries
/// that traverse the hierarchy will miss entries.
pub async fn verify_closure_table_integrity(&self) -> anyhow::Result<()> {
use sea_orm::sea_query::Expr;
use std::collections::HashSet;
let db = self.harness.library.db().conn();
let location_id = self
.entry_id
.ok_or_else(|| anyhow::anyhow!("Location has no entry_id"))?;
// Get all entries that should be in the location (via parent_id traversal)
let mut all_entries_via_parent = HashSet::new();
let mut queue = vec![location_id];
while let Some(parent_id) = queue.pop() {
all_entries_via_parent.insert(parent_id);
let children = entities::entry::Entity::find()
.filter(entities::entry::Column::ParentId.eq(parent_id))
.all(db)
.await?;
for child in children {
queue.push(child.id);
}
}
// Get all entries via closure table
let entries_via_closure: HashSet<i32> = entry_closure::Entity::find()
.filter(entry_closure::Column::AncestorId.eq(location_id))
.all(db)
.await?
.into_iter()
.map(|ec| ec.descendant_id)
.collect();
// The closure table should contain ALL entries found via parent traversal
let missing_from_closure: Vec<_> = all_entries_via_parent
.difference(&entries_via_closure)
.collect();
if !missing_from_closure.is_empty() {
// Get details about the missing entries for better error messages
let missing_entries = entities::entry::Entity::find()
.filter(
entities::entry::Column::Id
.is_in(missing_from_closure.iter().copied().copied()),
)
.all(db)
.await?;
let mut error_msg = format!(
"❌ Closure table is corrupted! {} entries are missing from closure table but exist via parent_id:\n",
missing_from_closure.len()
);
for entry in missing_entries.iter().take(10) {
error_msg.push_str(&format!(
" - Entry {} (name: '{}', kind: {})\n",
entry.id, entry.name, entry.kind
));
}
if missing_entries.len() > 10 {
error_msg.push_str(&format!(" ... and {} more\n", missing_entries.len() - 10));
}
anyhow::bail!(error_msg);
}
tracing::debug!(
"✅ Closure table integrity verified: {} entries properly connected",
all_entries_via_parent.len()
);
Ok(())
}
}

View File

@@ -5,12 +5,12 @@
//! detection by tracking inodes and buffering events.
//!
//! Key features:
//! - Inode-based rename detection
//! - Inode-based rename detection for both files and directories
//! - Three-phase event buffering (creates, updates, removes)
//! - Timeout-based eviction for unmatched events
//! - Finder duplicate directory event deduplication
//! - Reincident file tracking for files with rapid successive changes
//! - Immediate emission for directories, buffered emission for files
//! - Buffered emission for rename detection
use crate::event::{FsEvent, RawEventKind, RawNotifyEvent};
use crate::platform::EventHandler;
@@ -55,6 +55,10 @@ pub struct MacOsHandler {
/// Recently created directories - for duplicate event deduplication
/// Key: path, Value: timestamp of creation
recent_dirs: RwLock<HashMap<PathBuf, Instant>>,
/// Inode cache for paths we've seen - allows rename detection even after file is moved
/// Key: path, Value: (inode, timestamp)
inode_cache: RwLock<HashMap<PathBuf, (u64, Instant)>>,
}
#[derive(Debug, Clone)]
@@ -81,6 +85,7 @@ impl MacOsHandler {
pending_updates: RwLock::new(HashMap::new()),
reincident_updates: RwLock::new(HashMap::new()),
recent_dirs: RwLock::new(HashMap::new()),
inode_cache: RwLock::new(HashMap::new()),
}
}
@@ -118,31 +123,23 @@ impl MacOsHandler {
/// Process create events, attempting rename matching
async fn process_create(&self, path: PathBuf) -> Result<Vec<FsEvent>> {
// Check if this is a directory
if Self::is_directory(&path).await {
// Dedupe duplicate directory creation events using recent_dirs cache
{
let mut recent = self.recent_dirs.write().await;
if recent.contains_key(&path) {
trace!(
"Ignoring duplicate directory create event: {}",
path.display()
);
return Ok(vec![]);
}
// Track this directory creation
recent.insert(path.clone(), Instant::now());
}
let is_dir = Self::is_directory(&path).await;
// Directories emit immediately (no rename detection needed)
debug!(
"Directory created, emitting immediately: {}",
path.display()
);
return Ok(vec![FsEvent::create_dir(path)]);
// Check if this is a directory and dedupe if needed
if is_dir {
let recent = self.recent_dirs.read().await;
if recent.contains_key(&path) {
trace!(
"Ignoring duplicate directory create event: {}",
path.display()
);
return Ok(vec![]);
}
// Note: Don't add to recent_dirs yet - only add when actually emitted
// to avoid interfering with buffered rename detection
}
// For files, check if we already have this path in recent_dirs
// Check if we already have this path in recent_dirs
// (edge case: directory metadata check failed initially but file is actually a dir)
{
let recent = self.recent_dirs.read().await;
@@ -155,16 +152,37 @@ impl MacOsHandler {
}
}
// For files, get inode for rename detection
// Get inode for rename detection (works for both files and directories)
let Some(inode) = Self::get_inode(&path).await else {
// File might have been deleted already
debug!("Could not get inode for created file: {}", path.display());
return Ok(vec![FsEvent::create(path)]);
// Path might have been deleted already
debug!("Could not get inode for created path: {}", path.display());
let event = if is_dir {
FsEvent::create_dir(path)
} else {
FsEvent::create(path)
};
return Ok(vec![event]);
};
// Cache the inode for this path to enable rename detection
{
let mut cache = self.inode_cache.write().await;
cache.insert(path.clone(), (inode, Instant::now()));
}
// Check if this matches a pending remove (rename)
if let Some(from_path) = self.try_match_rename(&path, inode).await {
return Ok(vec![FsEvent::rename(from_path, path)]);
let event = if is_dir {
// Add to recent_dirs to prevent duplicate events for the renamed directory
{
let mut recent = self.recent_dirs.write().await;
recent.insert(path.clone(), Instant::now());
}
FsEvent::rename_with_dir_flag(from_path, path, true)
} else {
FsEvent::rename(from_path, path)
};
return Ok(vec![event]);
}
// Buffer the create for potential later rename matching
@@ -206,7 +224,15 @@ impl MacOsHandler {
}
// Try to get inode from the filesystem (file might still exist briefly)
if let Some(inode) = Self::get_inode(&path).await {
let inode = if let Some(inode) = Self::get_inode(&path).await {
Some(inode)
} else {
// File is already gone, try to get inode from cache
let cache = self.inode_cache.read().await;
cache.get(&path).map(|(inode, _)| *inode)
};
if let Some(inode) = inode {
// Buffer for potential rename matching
let mut removes = self.pending_removes.write().await;
removes.insert(
@@ -217,11 +243,19 @@ impl MacOsHandler {
timestamp: Instant::now(),
},
);
trace!("Buffered remove for rename detection: {}", path.display());
trace!(
"Buffered remove for rename detection: {} (inode {})",
path.display(),
inode
);
return Ok(vec![]);
}
// File is gone and we couldn't get inode - emit remove
// File is gone and we couldn't get inode from filesystem or cache - emit remove
debug!(
"No inode found for remove event, emitting immediately: {}",
path.display()
);
Ok(vec![FsEvent::remove(path)])
}
@@ -376,6 +410,13 @@ impl MacOsHandler {
let mut recent = self.recent_dirs.write().await;
recent.retain(|_, timestamp| timestamp.elapsed() < timeout);
}
/// Clean up old entries from inode cache
async fn cleanup_inode_cache(&self, timeout: Duration) {
let mut cache = self.inode_cache.write().await;
let now = Instant::now();
cache.retain(|_, (_, timestamp)| now.duration_since(*timestamp) < timeout);
}
}
impl Default for MacOsHandler {
@@ -455,6 +496,9 @@ impl EventHandler for MacOsHandler {
// Clean up old entries from recent_dirs cache
self.cleanup_recent_dirs(dir_dedup_timeout).await;
// Clean up old entries from inode cache (use same timeout as dir dedup)
self.cleanup_inode_cache(dir_dedup_timeout).await;
Ok(events)
}
@@ -464,6 +508,7 @@ impl EventHandler for MacOsHandler {
self.pending_updates.write().await.clear();
self.reincident_updates.write().await.clear();
self.recent_dirs.write().await.clear();
self.inode_cache.write().await.clear();
}
}
@@ -481,6 +526,7 @@ mod tests {
assert!(handler.pending_updates.read().await.is_empty());
assert!(handler.reincident_updates.read().await.is_empty());
assert!(handler.recent_dirs.read().await.is_empty());
assert!(handler.inode_cache.read().await.is_empty());
}
#[tokio::test]

View File

@@ -145,26 +145,41 @@ export function PopoutInspector() {
// Listen for selection changes from main window
useEffect(() => {
if (platform.onSelectedFilesChanged) {
let unlisten: (() => void) | undefined;
if (!platform.onSelectedFilesChanged) return;
platform.onSelectedFilesChanged((fileIds) => {
let unlisten: (() => void) | undefined;
let mounted = true;
platform.onSelectedFilesChanged((fileIds) => {
if (mounted) {
console.log("[PopoutInspector] Received selection change:", fileIds);
setSelectedFileIds(fileIds);
}).then((unlistenFn) => {
}
}).then((unlistenFn) => {
if (mounted) {
unlisten = unlistenFn;
}).catch((err) => {
console.error("Failed to listen for selected files changes:", err);
});
} else {
// Component unmounted before listener was set up, clean up immediately
unlistenFn();
}
}).catch((err) => {
console.error("Failed to listen for selected files changes:", err);
});
return () => {
unlisten?.();
};
}
return () => {
mounted = false;
unlisten?.();
};
}, [platform]);
// Fetch the first selected file
const firstFileId = selectedFileIds[0] || null;
console.log("[PopoutInspector] Current state:", {
selectedFileIds,
firstFileId,
});
const { data: file, isLoading } = useLibraryQuery(
{
type: "files.by_id",
@@ -175,6 +190,11 @@ export function PopoutInspector() {
}
);
console.log("[PopoutInspector] Query result:", {
file: file?.id,
isLoading,
});
// Compute inspector variant
const variant: InspectorVariant = file
? { type: "file", file }

View File

@@ -30,6 +30,7 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
const fileIds = selectedFiles.map((f) => f.id);
if (platform.setSelectedFileIds) {
console.log("[SelectionContext] Syncing selected files to platform:", fileIds);
platform.setSelectedFileIds(fileIds).catch((err) => {
console.error("Failed to sync selected files to platform:", err);
});

View File

@@ -169,29 +169,11 @@ export function useNormalizedQuery<I, O>(
const capturedQueryKey = queryKey;
const handleEvent = (event: Event) => {
// Debug: log every batch event to understand what's happening
if (typeof event !== "string" && "ResourceChangedBatch" in event) {
const batch = (event as any).ResourceChangedBatch;
console.log("[useNormalizedQuery] Batch event received", {
capturedPath: capturedPathScope,
currentRefPath: optionsRef.current.pathScope,
pathsMatch:
JSON.stringify(optionsRef.current.pathScope) ===
JSON.stringify(capturedPathScope),
resourceCount: batch.resources?.length || 0,
resourceType: batch.resource_type,
});
}
// Guard: only process events if pathScope hasn't changed since subscription
if (
JSON.stringify(optionsRef.current.pathScope) !==
JSON.stringify(capturedPathScope)
) {
// console.log("[useNormalizedQuery] Dropping stale event", {
// eventPathScope: capturedPathScope,
// currentPathScope: optionsRef.current.pathScope,
// });
return;
}
@@ -215,22 +197,13 @@ export function useNormalizedQuery<I, O>(
)
.then((unsub) => {
if (isCancelled) {
// console.log(
// "[useNormalizedQuery] Subscription cancelled before creation completed",
// );
unsub();
} else {
// console.log("[useNormalizedQuery] Subscription active", {
// pathScope: options.pathScope,
// });
unsubscribe = unsub;
}
});
return () => {
// console.log("[useNormalizedQuery] Cleaning up subscription", {
// pathScope: options.pathScope,
// });
isCancelled = true;
unsubscribe?.();
};
@@ -277,10 +250,6 @@ export function handleResourceEvent(
if ("ResourceChanged" in event) {
const result = v.safeParse(ResourceChangedSchema, event);
if (!result.success) {
// console.warn(
// "[useNormalizedQuery] Invalid ResourceChanged event:",
// result.issues,
// );
return;
}
@@ -301,10 +270,6 @@ export function handleResourceEvent(
else if ("ResourceChangedBatch" in event) {
const result = v.safeParse(ResourceChangedBatchSchema, event);
if (!result.success) {
// console.warn(
// "[useNormalizedQuery] Invalid ResourceChangedBatch event:",
// result.issues,
// );
return;
}
@@ -329,10 +294,6 @@ export function handleResourceEvent(
else if ("ResourceDeleted" in event) {
const result = v.safeParse(ResourceDeletedSchema, event);
if (!result.success) {
// console.warn(
// "[useNormalizedQuery] Invalid ResourceDeleted event:",
// result.issues,
// );
return;
}
@@ -424,16 +385,6 @@ export function filterBatchResources(
// Only match if parent equals scope (normalized)
return parentDir === normalizedScope;
});
// const afterCount = filtered.length;
// if (beforeCount !== afterCount) {
// console.log("[filterBatchResources] Filtered resources", {
// pathScope: options.pathScope,
// before: beforeCount,
// after: afterCount,
// filtered: beforeCount - afterCount,
// });
// }
}
return filtered;
@@ -460,10 +411,6 @@ export function updateSingleResource<O>(
if (options) {
resourcesToUpdate = filterBatchResources(resourcesToUpdate, options);
if (resourcesToUpdate.length === 0) {
// console.log("[updateSingleResource] Filtered out resource", {
// pathScope: options.pathScope,
// resourcePath: resource.sd_path,
// });
return; // Resource was filtered out
}
}
@@ -510,53 +457,31 @@ export function updateBatchResources<O>(
// Apply client-side filtering (safety fallback)
const filteredResources = filterBatchResources(resources, options);
console.log("[updateBatchResources]", {
totalResources: resources.length,
afterFilter: filteredResources.length,
pathScope: options.pathScope,
queryKey,
});
if (filteredResources.length === 0) {
console.log("[updateBatchResources] No matching resources after filter");
return; // No matching resources
}
queryClient.setQueryData<O>(queryKey, (oldData: any) => {
if (!oldData) {
console.log("[updateBatchResources] No oldData in cache");
return oldData;
}
if (!oldData) return oldData;
// Handle array responses
if (Array.isArray(oldData)) {
const updated = updateArrayCache(
return updateArrayCache(
oldData,
filteredResources,
noMergeFields,
) as O;
console.log("[updateBatchResources] Updated array cache", {
oldCount: oldData.length,
newCount: (updated as any[]).length,
});
return updated;
}
// Handle wrapped responses { files: [...] }
if (oldData && typeof oldData === "object") {
const updated = updateWrappedCache(
return updateWrappedCache(
oldData,
filteredResources,
noMergeFields,
) as O;
console.log("[updateBatchResources] Updated wrapped cache");
return updated;
}
console.log("[updateBatchResources] Cache data type not recognized", {
isArray: Array.isArray(oldData),
type: typeof oldData,
});
return oldData;
});
}
@@ -647,7 +572,6 @@ function updateWrappedCache(
// This handles single object responses like files.by_id
const match = newResources.find((r: any) => r.id === oldData.id);
if (match) {
console.log("[updateWrappedCache] Direct merge (single object response)");
return safeMerge(oldData, match, noMergeFields);
}
@@ -656,73 +580,38 @@ function updateWrappedCache(
Array.isArray(oldData[key]),
);
console.log("[updateWrappedCache]", {
arrayField,
oldDataKeys: Object.keys(oldData),
newResourcesCount: newResources.length,
newResourceIds: newResources.map((r) => r.id),
});
if (arrayField) {
const array = [...oldData[arrayField]];
const seenIds = new Set();
console.log("[updateWrappedCache] Before update", {
arrayField,
oldArrayLength: array.length,
existingIds: array.map((i: any) => i.id),
});
// Update existing
let updatedCount = 0;
for (let i = 0; i < array.length; i++) {
const item: any = array[i];
const match = newResources.find((r: any) => r.id === item.id);
if (match) {
array[i] = safeMerge(item, match, noMergeFields);
seenIds.add(item.id);
updatedCount++;
}
}
// Append new
let appendedCount = 0;
for (const resource of newResources) {
if (!seenIds.has(resource.id)) {
// Check if resource already exists in the array (by ID)
// This handles Content-path resources that might have been updated
const alreadyExists = array.some((item: any) => item.id === resource.id);
if (alreadyExists) {
// Resource already in cache, was already updated above
console.log("[updateWrappedCache] Resource already in cache, skipping append", {
id: resource.id,
name: resource.name,
});
continue;
}
// New resource - append it (including Content paths for new files!)
array.push(resource);
appendedCount++;
console.log("[updateWrappedCache] Appended new resource", {
id: resource.id,
name: resource.name,
sdPath: resource.sd_path,
});
}
}
console.log("[updateWrappedCache] After update", {
updatedCount,
appendedCount,
newArrayLength: array.length,
});
return { ...oldData, [arrayField]: array };
}
console.log("[updateWrappedCache] No array field found, returning oldData unchanged");
return oldData;
}