//! File system access for the IDE. The UI never touches the disk directly. mod archive; mod bytes; mod compare; mod encoding; mod ops; mod rules; mod watch; use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; use std::cmp::Ordering; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; pub use archive::{ ARCHIVE_EXTENSIONS, archive_entry_path, archive_root, is_archive_file, is_archive_path, read_archive_dir, read_archive_entry, split_archive_path, }; pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range}; pub use compare::{ DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge, }; use encoding::SNIFF_LEN; pub use encoding::{ LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode, encode_properties_ascii, save_properties_text, write_atomic, write_atomic_strict, }; pub use rules::ExcludeMatcher; pub use watch::{WatchMode, Watcher}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum EntryKind { File, Dir, Symlink, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct FileEntry { pub name: String, pub path: ide_core::IpcPath, pub kind: EntryKind, pub size: u64, /// Milliseconds since the Unix epoch. pub mtime_ms: f64, /// Matched by .gitignore or the exclude settings. pub ignored: bool, /// Matched by the exclude settings (IDEA's "excluded": dimmed, excluded-folder icon). pub excluded: bool, pub hidden: bool, /// For symlinks: what the link points to (`None` when dangling); `None` /// for other entries. #[specta(optional)] pub target_kind: Option, } /// Milliseconds since the Unix epoch of a file's modification time (0 when unknown). pub fn mtime_ms(meta: &fs::Metadata) -> f64 { meta.modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_nanos() as f64 / 1_000_000.0) .unwrap_or(0.0) } /// Case-insensitive natural ordering: `file2 < file10`, `a < B`. pub fn natural_cmp(a: &str, b: &str) -> Ordering { let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable()); loop { match (x.peek().copied(), y.peek().copied()) { (None, None) => break, (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => { let take = |it: &mut std::iter::Peekable>| { let mut s = String::new(); while let Some(c) = it.peek().copied().filter(char::is_ascii_digit) { s.push(c); it.next(); } s }; let (n, m) = (take(&mut x), take(&mut y)); let (nt, mt) = (n.trim_start_matches('0'), m.trim_start_matches('0')); let ord = nt.len().cmp(&mt.len()).then_with(|| nt.cmp(mt)).then_with(|| n.len().cmp(&m.len())); if ord != Ordering::Equal { return ord; } } (Some(c), Some(d)) => { let ord = c.to_lowercase().cmp(d.to_lowercase()); if ord != Ordering::Equal { return ord; } x.next(); y.next(); } } } a.cmp(b) } /// Lists one directory level: directories first, then files, each sorted /// case-insensitively with natural number ordering. /// /// Symlinks are reported as [`EntryKind::Symlink`] but sorted (and sized) by /// what they point to. `excluded` reflects `excludes` (relative to the enclosing /// repository root, else to `dir`); `ignored` also the applicable `.gitignore`/`.ignore` files. /// /// Inside an archive (`!/`, or the archive file itself) lists its entries. pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult> { if let Some((archive, entry)) = split_archive_path(dir) { return read_archive_dir(&archive, &entry); } if is_archive_file(dir) && fs::metadata(dir).is_ok_and(|m| m.is_file()) { return read_archive_dir(dir, ""); } if !fs::metadata(dir)?.is_dir() { return Err(IdeError::invalid(format!("{} is not a directory", dir.display()))); } let gitignore = rules::GitignoreStack::new(dir); let base = gitignore.repo_root().unwrap_or(dir).to_path_buf(); let excludes = ExcludeMatcher::new(excludes); let mut entries: Vec<(bool, FileEntry)> = Vec::new(); for entry in fs::read_dir(dir)? { let entry = match entry { Ok(e) => e, Err(e) => { tracing::debug!("read_dir {}: {e}", dir.display()); continue; } }; let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); let Ok(lmeta) = fs::symlink_metadata(&path) else { continue }; let (kind, meta, target_kind) = if lmeta.file_type().is_symlink() { match fs::metadata(&path) { Ok(m) => { let target = if m.is_dir() { EntryKind::Dir } else { EntryKind::File }; (EntryKind::Symlink, m, Some(target)) } Err(_) => (EntryKind::Symlink, lmeta, None), } } else if lmeta.is_dir() { (EntryKind::Dir, lmeta, None) } else { (EntryKind::File, lmeta, None) }; let is_dir = meta.is_dir(); let rel = path.strip_prefix(&base).unwrap_or(&path); let excluded = excludes.is_excluded(rel); let ignored = excluded || gitignore.is_ignored(&path, is_dir); entries.push(( is_dir, FileEntry { hidden: name.starts_with('.'), size: if is_dir { 0 } else { meta.len() }, mtime_ms: mtime_ms(&meta), name, path: path.into(), kind, ignored, excluded, target_kind, }, )); } entries.sort_by(|(da, a), (db, b)| db.cmp(da).then_with(|| natural_cmp(&a.name, &b.name))); Ok(entries.into_iter().map(|(_, e)| e).collect()) } #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct LoadedText { pub text: String, /// WHATWG encoding label, e.g. `utf-8`, `gbk`, `utf-16le`. pub encoding: String, pub has_bom: bool, /// File looked binary; `text` is empty and the UI should show a placeholder. pub binary: bool, /// File exceeds [`LARGE_FILE_THRESHOLD`]: expensive features are disabled. #[specta(optional)] pub large: bool, /// File exceeds [`READ_ONLY_THRESHOLD`]: opened read-only. pub too_large: bool, pub mtime_ms: f64, } /// Loads a text file, detecting BOM / encoding (chardetng) and binary content. /// /// Files over [`LARGE_FILE_THRESHOLD`] load with `large`, over /// [`READ_ONLY_THRESHOLD`] with `too_large`; files over [`MAX_OPEN_SIZE`] are /// refused with `InvalidInput`. pub fn load_text(path: &Path) -> IdeResult { encoding::load_text(path) } /// Like [`load_text`], but text that is neither valid UTF-8 nor marked by a BOM /// is decoded as `fallback` (the configured project encoding) instead of guessed. pub fn load_text_with_fallback(path: &Path, fallback: Option<&str>) -> IdeResult { let loaded = encoding::load_text(path)?; match fallback { Some(enc) if !loaded.binary && !loaded.has_bom && loaded.encoding != "utf-8" && !loaded.encoding.starts_with("utf-16") => { encoding::load_text_as(path, enc).or(Ok(loaded)) } _ => Ok(loaded), } } /// Like [`load_text`] but decodes with the given encoding instead of detecting it. pub fn load_text_as(path: &Path, encoding: &str) -> IdeResult { encoding::load_text_as(path, encoding) } /// Writes atomically (temp file + rename), re-encoding to `encoding`. /// Returns the new mtime in ms. Fails with `InvalidInput` when the text has /// characters `encoding` cannot represent (rather than corrupting them). pub fn save_text(path: &Path, text: &str, encoding: &str, has_bom: bool) -> IdeResult { let bytes = encode(text, encoding, has_bom)?; write_atomic(path, &bytes) } /// Creates an empty file (and missing parent directories). Fails with /// `AlreadyExists` rather than truncating an existing file. pub fn create_file(path: &Path) -> IdeResult<()> { ops::create_file(path) } /// Creates a binary file exclusively, including missing parent directories. pub fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> { ops::create_file_bytes(path, data) } /// Creates a directory and missing parents; `AlreadyExists` if present. pub fn create_dir(path: &Path) -> IdeResult<()> { ops::create_dir(path) } /// Sets or clears the file's writable bit (owner write when making it writable). pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> { ops::set_writable(path, writable) } /// Renames / moves; refuses to overwrite an existing target (case-only renames /// are allowed). Falls back to copy + delete across devices. pub fn rename(from: &Path, to: &Path) -> IdeResult<()> { ops::rename(from, to) } /// Checks source existence and destination conflicts without mutation, using /// the same filesystem identity rules as [`rename`]. pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> { ops::validate_rename(from, to) } /// Moves to the OS trash (recoverable), like IDEA's safe delete for files. /// Deletes permanently when the trash is unavailable. pub fn delete(path: &Path) -> IdeResult<()> { ops::delete(path, !cfg!(test)) } /// Deletes without going through the trash. pub fn delete_permanently(path: &Path) -> IdeResult<()> { ops::delete(path, false) } /// Copies a file, symlink or directory tree; refuses to overwrite `to`. pub fn copy(from: &Path, to: &Path) -> IdeResult<()> { ops::copy(from, to) } /// Walks every non-ignored file of a project in parallel (ignore crate), /// honouring .gitignore, .ignore and `excludes`. Shared by index and search. /// /// Hidden files are included; `.git` is always skipped. `.gitignore` applies /// even outside a git repository; inside one, a nested repository's root is a /// boundary for the outer repository's rules. Symlinks to files are visited, symlinked /// directories are not followed. pub fn walk_files(root: &Path, excludes: &[String], visit: impl Fn(&Path) + Send + Sync) { let excludes = Arc::new(ExcludeMatcher::new(excludes)); let base = root.to_path_buf(); let mut builder = ignore::WalkBuilder::new(root); builder.hidden(false).require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false); builder.filter_entry(move |entry| { if entry.depth() > 0 && entry.file_name() == ".git" { return false; } match entry.path().strip_prefix(&base) { Ok(rel) if !rel.as_os_str().is_empty() => !excludes.is_match(rel), _ => true, } }); let visit = &visit; builder.build_parallel().run(|| { Box::new(move |result| { match result { Ok(entry) => { let is_file = match entry.file_type() { Some(ft) if ft.is_file() => true, Some(ft) if ft.is_symlink() => entry.path().is_file(), _ => false, }; if is_file { visit(entry.path()); } } Err(e) => tracing::debug!("walk: {e}"), } ignore::WalkState::Continue }) }); } #[derive(Clone, Debug, Default)] pub struct FsBatch { pub created: Vec, pub modified: Vec, pub removed: Vec, /// VCS metadata (`.git/HEAD`, `index`, `refs/**`): reported separately so /// generic `fsChanged` consumers do not treat the backend's own index /// refresh as a worktree edit and schedule another `vcs_status` (which /// writes `.git/index` again — an idle refresh loop). pub vcs: Vec, /// Folders holding changes filtered out as excluded or ignored. Only a view already /// showing such a folder cares, so they don't count as worktree changes. pub excluded_dirs: Vec, /// Files changed, created or removed that an ignore rule matches while their folder /// is not ignored (`.env`). Kept apart from the worktree changes above (no Local /// History, deployment or `fsChanged`), but the index lists them and open editors /// follow them, like IDEA, where only ignored folders leave the project. pub ignored: Vec, } impl FsBatch { pub fn is_empty(&self) -> bool { self.created.is_empty() && self.modified.is_empty() && self.removed.is_empty() && self.vcs.is_empty() && self.excluded_dirs.is_empty() && self.ignored.is_empty() } } /// Detect a revision's encoding with the same rules as files opened in the editor. pub fn detect_encoding(bytes: &[u8], fallback: Option<&str>) -> &'static encoding_rs::Encoding { let (_, detected, has_bom, binary) = encoding::decode(bytes); if !binary && !has_bom && detected != encoding_rs::UTF_8 && detected != encoding_rs::UTF_16LE && detected != encoding_rs::UTF_16BE { fallback.and_then(|label| encoding_rs::Encoding::for_label(label.trim().as_bytes())).unwrap_or(detected) } else { detected } } #[cfg(test)] mod tests { use super::*; #[test] fn natural_ordering() { let mut names = vec!["file10", "File2", "file1", "b", "A", "file02", "a", "x9y", "x10y", "readme"]; names.sort_by(|a, b| natural_cmp(a, b)); assert_eq!(names, vec!["A", "a", "b", "file1", "File2", "file02", "file10", "readme", "x9y", "x10y"]); } #[test] fn toggles_the_writable_bit() { let tmp = tempfile::tempdir().unwrap(); let f = tmp.path().join("x"); fs::write(&f, "1").unwrap(); set_writable(&f, false).unwrap(); assert!(fs::metadata(&f).unwrap().permissions().readonly()); set_writable(&f, true).unwrap(); assert!(!fs::metadata(&f).unwrap().permissions().readonly()); assert_eq!( set_writable(&tmp.path().join("missing"), true).unwrap_err().kind, ide_core::error::ErrorKind::NotFound ); } #[test] fn delete_in_tests_is_permanent() { let tmp = tempfile::tempdir().unwrap(); let f = tmp.path().join("x"); fs::write(&f, "1").unwrap(); delete(&f).unwrap(); assert!(!f.exists()); assert_eq!(delete(&f).unwrap_err().kind, ide_core::error::ErrorKind::NotFound); } } #[cfg(test)] mod assertions { fn send_sync() {} #[test] fn types_are_thread_safe() { send_sync::(); send_sync::(); } }