From 3739b3f34ff08e70ef79d9aba4f533204a8abd49 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 8 Dec 2025 00:38:04 -0800 Subject: [PATCH] Enhance ephemeral indexing and add filesystem watching support - Updated `EphemeralIndex` to preserve explicitly browsed subdirectories during re-indexing, preventing loss of user navigation context. - Modified `clear_directory_children` to return the count of cleared entries and a list of deleted browsed directories. - Introduced `EphemeralIndexCache` enhancements to support filesystem watching, allowing paths to be monitored for changes. - Added methods for registering, unregistering, and checking watched paths, improving the responsiveness of the indexing system. - Updated documentation and tests to reflect new functionality and ensure reliability. --- .../src/ops/indexing/ephemeral/index_cache.rs | 18 +- core/src/ops/indexing/job.rs | 98 +++++-- docs/workbench | 2 +- .../Explorer/components/AddStorageModal.tsx | 169 +++++++---- .../Explorer/components/PathBar.tsx | 263 ++++++++++-------- 5 files changed, 354 insertions(+), 196 deletions(-) diff --git a/core/src/ops/indexing/ephemeral/index_cache.rs b/core/src/ops/indexing/ephemeral/index_cache.rs index 4716b7439..93fe98dcd 100644 --- a/core/src/ops/indexing/ephemeral/index_cache.rs +++ b/core/src/ops/indexing/ephemeral/index_cache.rs @@ -100,11 +100,23 @@ impl EphemeralIndexCache { /// Clear stale entries for a path before re-indexing (async version) /// - /// Call this after create_for_indexing to remove old children entries. - /// This prevents ghost entries when files are deleted between index runs. + /// Removes files and unbrowsed subdirectories, preserving subdirectories + /// that were explicitly navigated to. Verifies preserved directories still + /// exist on the filesystem and removes deleted ones from tracking. pub async fn clear_for_reindex(&self, path: &Path) -> usize { + let indexed = self.indexed_paths.read().clone(); let mut index = self.index.write().await; - index.clear_directory_children(path) + let (cleared, deleted_browsed_dirs) = index.clear_directory_children(path, &indexed); + + // Remove deleted browsed directories from indexed_paths + if !deleted_browsed_dirs.is_empty() { + let mut indexed_paths = self.indexed_paths.write(); + for deleted_path in deleted_browsed_dirs { + indexed_paths.remove(&deleted_path); + } + } + + cleared } /// Mark indexing as complete for a path diff --git a/core/src/ops/indexing/job.rs b/core/src/ops/indexing/job.rs index fab94fd67..ee301beac 100644 --- a/core/src/ops/indexing/job.rs +++ b/core/src/ops/indexing/job.rs @@ -451,44 +451,86 @@ impl EphemeralIndex { ) } - /// Clears immediate children of a directory to prepare for re-indexing. + /// Clears entries before re-indexing, preserving explicitly browsed subdirectories. /// - /// This prevents ghost entries when files are deleted between index runs. - /// The arena nodes become orphaned but remain allocated, which is acceptable - /// for ephemeral indexes since memory pressure triggers full eviction anyway. - /// Only clears the direct children (non-recursive). - pub fn clear_directory_children(&mut self, dir_path: &Path) -> usize { - let children_paths: Vec = if let Some(dir_id) = self.path_index.get(dir_path) { - if let Some(dir_node) = self.arena.get(*dir_id) { - dir_node - .children - .iter() - .filter_map(|&child_id| self.reconstruct_path(child_id)) - .collect() - } else { - return 0; - } - } else { - return 0; + /// Since ephemeral indexing is shallow, subdirectories that were explicitly + /// navigated to (in `indexed_paths`) should be preserved as separate index + /// branches. Unbrowsed subdirectories are refreshed with the parent. + /// + /// Returns (cleared_count, deleted_browsed_dirs) where deleted_browsed_dirs + /// contains paths that were in indexed_paths but no longer exist on disk. + pub fn clear_directory_children( + &mut self, + dir_path: &Path, + indexed_paths: &std::collections::HashSet, + ) -> (usize, Vec) { + let dir_id = match self.path_index.get(dir_path) { + Some(&id) => id, + None => return (0, Vec::new()), }; - let mut cleared = 0; + let dir_node = match self.arena.get(dir_id) { + Some(node) => node, + None => return (0, Vec::new()), + }; - for child_path in &children_paths { - if self.path_index.remove(child_path).is_some() { - cleared += 1; - } + let mut deleted_browsed_dirs = Vec::new(); + + // Collect children to remove + let mut children_to_remove: Vec<(PathBuf, super::ephemeral::EntryId)> = dir_node + .children + .iter() + .filter_map(|&child_id| { + let child_node = self.arena.get(child_id)?; + let child_path = self.reconstruct_path(child_id)?; + + // Preserve subdirectories that were explicitly browsed AND still exist + if child_node.is_directory() && indexed_paths.contains(&child_path) { + // Verify the directory still exists on the filesystem + if std::fs::metadata(&child_path).is_ok() { + return None; // Preserve - still exists and was browsed + } + // Directory was deleted - track for removal from indexed_paths + tracing::debug!( + "Removing deleted browsed directory: {}", + child_path.display() + ); + deleted_browsed_dirs.push(child_path.clone()); + } + + // Remove everything else (files, unbrowsed directories, deleted directories) + Some((child_path, child_id)) + }) + .collect(); + + let cleared = children_to_remove.len(); + + // Remove from indexes + for (child_path, _) in &children_to_remove { + self.path_index.remove(child_path); self.entry_uuids.remove(child_path); self.content_kinds.remove(child_path); } - if let Some(dir_id) = self.path_index.get(dir_path) { - if let Some(dir_node) = self.arena.get_mut(*dir_id) { - dir_node.children.clear(); - } + // Update parent's children list + if let Some(dir_node) = self.arena.get_mut(dir_id) { + let removed_ids: std::collections::HashSet<_> = + children_to_remove.iter().map(|(_, id)| id).collect(); + + dir_node + .children + .retain(|child_id| !removed_ids.contains(child_id)); } - cleared + if cleared > 0 { + tracing::debug!( + "Cleared {} entries from {} (preserved browsed subdirs)", + cleared, + dir_path.display() + ); + } + + (cleared, deleted_browsed_dirs) } fn reconstruct_path(&self, id: super::ephemeral::EntryId) -> Option { diff --git a/docs/workbench b/docs/workbench index cab1f9e49..351a8415f 160000 --- a/docs/workbench +++ b/docs/workbench @@ -1 +1 @@ -Subproject commit cab1f9e49e81f8622f2c77f8c1162f7cbd2b1b1d +Subproject commit 351a8415f43a8396c2c3370a09f72ebb2b36cd05 diff --git a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx index bcb5dbc2b..791f721c8 100644 --- a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx +++ b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx @@ -349,22 +349,34 @@ const jobOptions: JobOption[] = [ export function useAddStorageDialog( onStorageAdded?: (id: string) => void, + initialPath?: string, ) { return dialogManager.create((props) => ( - + )); } function AddStorageDialog(props: { id: number; onStorageAdded?: (id: string) => void; + initialPath?: string; }) { const dialog = useDialog(props); const platform = usePlatform(); - const [step, setStep] = useState("category"); + // Derive initial folder name from path + const initialFolderName = + props.initialPath?.split("/").filter(Boolean).pop() || ""; + + const [step, setStep] = useState( + props.initialPath ? "local-config" : "category", + ); const [selectedCategory, setSelectedCategory] = - useState(null); + useState(props.initialPath ? "local" : null); const [selectedProvider, setSelectedProvider] = useState(null); const [tab, setTab] = useState("preset"); @@ -385,8 +397,8 @@ function AddStorageDialog(props: { const localForm = useForm({ defaultValues: { - path: "", - name: "", + path: props.initialPath || "", + name: initialFolderName, mode: "Deep", }, }); @@ -404,7 +416,9 @@ function AddStorageDialog(props: { const currentMode = localForm.watch("mode"); const [selectedJobs, setSelectedJobs] = useState>( new Set( - jobOptions.filter((j) => j.presets.includes("Deep")).map((j) => j.id), + jobOptions + .filter((j) => j.presets.includes("Deep")) + .map((j) => j.id), ), ); @@ -539,7 +553,9 @@ function AddStorageDialog(props: { localForm.setError("root", { type: "manual", message: - error instanceof Error ? error.message : "Failed to add location", + error instanceof Error + ? error.message + : "Failed to add location", }); } }); @@ -692,7 +708,11 @@ function AddStorageDialog(props: { "border-app-line bg-app-box hover:bg-app-hover hover:border-accent/50", )} > - +
{category.label} @@ -733,7 +753,11 @@ function AddStorageDialog(props: { "border-app-line bg-app-box hover:bg-app-hover hover:border-accent/50", )} > - +
{provider.name}
@@ -761,8 +785,9 @@ function AddStorageDialog(props: {
Coming Soon

- Network protocol support (SMB, NFS, SFTP, WebDAV) is currently in - development. Check back in a future update! + Network protocol support (SMB, NFS, SFTP, WebDAV) is + currently in development. Check back in a future + update!

@@ -776,7 +801,11 @@ function AddStorageDialog(props: { "border-app-line bg-app-box", )} > - +
{protocol.name} @@ -820,17 +849,27 @@ function AddStorageDialog(props: { "border-app-line bg-app-box hover:bg-app-hover hover:border-accent/50", )} > - +
{volume.name}
- {volume.mount_point} • {volume.filesystem} + {volume.mount_point} •{" "} + {volume.filesystem}
- {volume.total_capacity ? (volume.total_capacity / 1e9).toFixed(0) : '?'} GB + {volume.total_capacity + ? ( + volume.total_capacity / 1e9 + ).toFixed(0) + : "?"}{" "} + GB
))} @@ -838,8 +877,8 @@ function AddStorageDialog(props: { ) : (

- No untracked external drives found. Connect a drive and refresh - to see it here. + No untracked external drives found. Connect a + drive and refresh to see it here.

)} @@ -867,7 +906,9 @@ function AddStorageDialog(props: {
localForm.setValue("path", e.target.value)} + onChange={(e) => + localForm.setValue("path", e.target.value) + } placeholder="Select a custom folder" size="lg" className="pr-14" @@ -880,34 +921,40 @@ function AddStorageDialog(props: {
- {suggestedLocations && suggestedLocations.locations.length > 0 && ( -
- -
- {suggestedLocations.locations.map((loc) => ( -
- - ))} + + ))} +
-
- )} + )}
); @@ -939,11 +986,16 @@ function AddStorageDialog(props: { />
- setTab(v as SettingsTab)}> + setTab(v as SettingsTab)} + > Preset - Jobs {selectedJobs.size > 0 && `(${selectedJobs.size})`} + Jobs{" "} + {selectedJobs.size > 0 && + `(${selectedJobs.size})`} @@ -952,12 +1004,15 @@ function AddStorageDialog(props: {
{indexModes.map((mode) => { - const isSelected = currentMode === mode.value; + const isSelected = + currentMode === mode.value; return ( + )} + + + + ) : ( + +
+
+ Path is outside any location +
+
-
- - - - - - {!isIndexed && ( - - )} - - + + )} - ); } @@ -257,71 +302,69 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) { "focus-within:bg-sidebar-box/30 focus-within:border-sidebar-line/40", )} > - Device + Device - {showUri ? ( - - ) : isExpanded ? ( -
- {segments.map((segment, index) => { - const isLast = index === segments.length - 1; - return ( -
- - {!isLast && ( - - )} -
- ); - })} -
- ) : ( - - )} + + {!isLast && } + + ); + })} + + ) : ( + + )}