diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8d8062e..59f78b7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,8 @@ "allow": [ { "path": "$HOME" }, { "path": "$HOME/**" }, + { "path": "$HOME/.docsreader" }, + { "path": "$HOME/.docsreader/**" }, { "path": "$DESKTOP" }, { "path": "$DESKTOP/**" }, { "path": "$DOCUMENT" }, @@ -84,6 +86,8 @@ "allow": [ { "path": "$HOME" }, { "path": "$HOME/**" }, + { "path": "$HOME/.docsreader" }, + { "path": "$HOME/.docsreader/**" }, { "path": "$DESKTOP" }, { "path": "$DESKTOP/**" }, { "path": "$DOCUMENT" }, diff --git a/src-tauri/core/src/scan.rs b/src-tauri/core/src/scan.rs index 3a42b9b..a25c986 100644 --- a/src-tauri/core/src/scan.rs +++ b/src-tauri/core/src/scan.rs @@ -1,7 +1,7 @@ use std::fs::File; use std::io::Read; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -51,6 +51,11 @@ pub struct ScanResult { pub root: String, pub files: Vec, pub truncated: bool, + // Files the scan could not include: unreadable entries, permission-denied + // subtrees, symlink loops, and files over MAX_FILE_BYTES. Default keeps + // cached results from older versions deserializable. + #[serde(default)] + pub skipped: usize, #[serde(default, skip_serializing_if = "Option::is_none")] pub marker: Option, } @@ -125,6 +130,12 @@ fn is_skipped_dir(name: &str) -> bool { SKIP_DIRS.contains(&name) } +/// True when a directory name collides with an entry the scanner prunes; +/// docs placed under such a folder would never appear in the GUI. +pub fn is_reserved_dir_name(name: &str) -> bool { + SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(name)) +} + pub(crate) fn is_markdown(name: &str) -> bool { let lower = name.to_ascii_lowercase(); lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx") @@ -149,9 +160,10 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result = Vec::new(); let mut truncated = false; + let skipped = AtomicUsize::new(0); let walker = WalkDir::new(root_path) - .follow_links(false) + .follow_links(true) .into_iter() .filter_entry(|e| { if e.depth() == 0 { @@ -165,7 +177,16 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result e, + // Permission-denied subtrees and symlink loops arrive as error + // entries; count them so the GUI can say files were left out. + Err(_) => { + skipped.fetch_add(1, Ordering::Relaxed); + continue; + } + }; if entry.file_type().is_dir() { dirs_visited.fetch_add(1, Ordering::Relaxed); maybe_emit_walk_progress( @@ -192,6 +213,7 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result MAX_FILE_BYTES { + skipped.fetch_add(1, Ordering::Relaxed); continue; } } @@ -209,7 +231,10 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result = entries .par_iter() .filter_map(|entry| { - let metadata = entry.metadata().ok()?; + let Ok(metadata) = entry.metadata() else { + skipped.fetch_add(1, Ordering::Relaxed); + return None; + }; let size = metadata.len(); let modified = metadata .modified() @@ -217,7 +242,10 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result Result Result<(), CoreError> { ), ); } + // A phase folder named like a pruned directory (build, dist, ...) would + // make its docs invisible to the GUI while writes still report success. + if crate::scan::is_reserved_dir_name(phase) { + return Err(CoreError::new( + ErrorCode::InvalidInput, + format!("phase {phase:?} is a reserved name"), + ) + .with_recovery(format!( + "choose a different phase name, e.g. \"{phase}-phase\"" + ))); + } Ok(()) } @@ -563,6 +574,41 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn rejects_reserved_phase_names() { + let root = test_dir("reserved_phase"); + for phase in ["build", "dist", "target", "venv"] { + let err = write_doc_core( + &root, + &NewDoc { + phase: Some(phase), + ..NewDoc::new("Doc", "x", DocStatus::Research) + }, + ) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidInput, "phase {phase:?}"); + assert!( + err.message.contains("reserved"), + "message says why: {}", + err.message + ); + assert!(err.recovery.is_some(), "suggests picking another name"); + } + + let ok = write_doc_core( + &root, + &NewDoc { + phase: Some("discovery"), + ..NewDoc::new("Doc", "x", DocStatus::Research) + }, + ) + .await + .unwrap(); + assert_eq!(ok.rel_path, "research/discovery/doc.md"); + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn move_unknown_slug_is_doc_not_found() { let root = test_dir("nomove"); diff --git a/src/components/explorer/ExplorerSidebar.tsx b/src/components/explorer/ExplorerSidebar.tsx index 5075271..76bde5c 100644 --- a/src/components/explorer/ExplorerSidebar.tsx +++ b/src/components/explorer/ExplorerSidebar.tsx @@ -321,6 +321,7 @@ function ExplorerFooter({ } const total = activeScan.result.files.length; const visible = total - hiddenCount; + const skipped = activeScan.result.skipped ?? 0; return ( @@ -328,6 +329,11 @@ function ExplorerFooter({ {activeScan.result.truncated && " (50k cap)"} {matchCount !== visible && ` ยท ${matchCount} match`} + {skipped > 0 && ( + + {skipped} skipped + + )} {hiddenCount > 0 && (