use std::collections::HashSet; /// A model for operating on files. /// /// Allows opening and saving files in a single, central model. Subscribers can watch for content /// when files are loaded, and request that content be saved to disk. use std::future::Future; use std::io; use std::ops::Range; use std::pin::Pin; use std::rc::Rc; use std::time::{Duration, SystemTime}; use std::{ collections::HashMap, path::{Path, PathBuf}, }; use galaxy_core::HostId; use galaxy_util::standardized_path::StandardizedPath; use remote_server::client::RemoteServerClient; use remote_server::manager::RemoteServerManager; use futures::io::{AsyncBufReadExt, BufReader}; use futures::StreamExt; use async_channel::Sender; use galaxy_util::content_version::ContentVersion; use galaxy_util::file::FileSaveError; use galaxy_util::file::{FileId, FileLoadError}; use galaxyui::ModelHandle; use galaxyui::{r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, SingletonEntity}; use notify_debouncer_full::notify::{RecursiveMode, WatchFilter}; use repo_metadata::{ repositories::DetectedRepositories, repository::{RepositorySubscriber, SubscriberId}, CanonicalizedPath, Repository, RepositoryUpdate, }; use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; pub mod text_file_reader; pub use text_file_reader::{TextFileReadResult, TextFileSegment}; #[derive(Debug)] pub enum FileModelEvent { FileLoaded { content: String, id: FileId, version: ContentVersion, }, FailedToLoad { id: FileId, error: Rc, }, FileSaved { id: FileId, version: ContentVersion, }, FailedToSave { id: FileId, error: Rc, }, FileUpdated { id: FileId, content: String, base_version: ContentVersion, new_version: ContentVersion, }, } impl FileModelEvent { pub fn file_id(&self) -> FileId { match self { Self::FileLoaded { id, .. } => *id, Self::FailedToLoad { id, .. } => *id, Self::FileSaved { id, .. } => *id, Self::FailedToSave { id, .. } => *id, Self::FileUpdated { id, .. } => *id, } } } /// Tracks how a file is being watched for changes. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum WatcherType { /// File is not being watched. #[default] None, /// File is watched via individual file watcher. Individual, /// File is watched via repository-level subscription. Repository, } /// Per-file backing store. /// Remote files dispatch through [`RemoteServerClient`] via [`RemoteServerManager`]. enum FileBackend { Local(LocalFile), Remote { /// Identifies the remote host. The actual client is looked up from /// [`RemoteServerManager`] at call time, which naturally handles /// disconnect (lookup returns `Err`) without holding an `Arc` alive /// per file. host_id: HostId, /// Platform-aware path on the remote host. path: StandardizedPath, }, } impl FileBackend { fn as_local(&self) -> Option<&LocalFile> { match self { FileBackend::Local(f) => Some(f), FileBackend::Remote { .. } => None, } } fn version(&self) -> Option { match self { FileBackend::Local(f) => f.version, FileBackend::Remote { .. } => None, } } fn set_version(&mut self, version: ContentVersion) { match self { FileBackend::Local(f) => f.version = Some(version), FileBackend::Remote { .. } => {} } } } #[derive(Default)] struct LocalFile { path: Option, version: Option, /// How this file is being watched for changes. watcher_type: WatcherType, } impl LocalFile { fn should_receive_update_for_path(&self, path: &Path) -> bool { self.path.as_deref() == Some(path) && self.subscribes_to_updates() && self.version.is_some() } } /// Tracks an active subscription to a repository for file change events. struct RepositorySubscription { repository: ModelHandle, /// The subscriber ID, set once the async subscription completes. subscriber_id: Option, } impl LocalFile { fn new(path: PathBuf, watcher_type: WatcherType) -> Self { // If we cannot canonicalize the path, it could be because the file does not exist on disk yet. // In this case, keep its original input path. let canonicalized_path = CanonicalizedPath::try_from(&path) .map(|path| path.as_path_buf().to_path_buf()) .unwrap_or(path); Self { path: Some(canonicalized_path), version: None, watcher_type, } } /// Returns true if this file is subscribed to receive update events. fn subscribes_to_updates(&self) -> bool { self.watcher_type != WatcherType::None } } /// Tracks files by ID with O(1) path reference counting. /// /// Encapsulates the file map and path refcount together so callers /// cannot forget to maintain the refcount invariant. #[derive(Default)] struct FileState { files: HashMap, /// Tracks how many FileIds reference each path, for O(1) "path still used" checks. path_refcount: HashMap, } impl FileState { fn insert_local(&mut self, file_id: FileId, local_file: LocalFile) { if let Some(ref path) = local_file.path { *self.path_refcount.entry(path.clone()).or_insert(0) += 1; } self.files.insert(file_id, FileBackend::Local(local_file)); } fn insert_remote(&mut self, file_id: FileId, host_id: HostId, path: StandardizedPath) { self.files .insert(file_id, FileBackend::Remote { host_id, path }); } /// Removes a file and returns the backend along with whether the local path /// is still referenced (always `false` for remote files). fn remove(&mut self, file_id: FileId) -> Option<(FileBackend, bool)> { let backend = self.files.remove(&file_id)?; let path_still_used = match &backend { FileBackend::Local(file) => { if let Some(ref path) = file.path { match self.path_refcount.get_mut(path) { Some(count) => { *count -= 1; if *count == 0 { self.path_refcount.remove(path); false } else { true } } None => false, } } else { false } } FileBackend::Remote { .. } => false, }; Some((backend, path_still_used)) } fn get(&self, file_id: FileId) -> Option<&FileBackend> { self.files.get(&file_id) } fn get_mut(&mut self, file_id: FileId) -> Option<&mut FileBackend> { self.files.get_mut(&file_id) } fn get_local(&self, file_id: FileId) -> Option<&LocalFile> { self.get(file_id).and_then(FileBackend::as_local) } fn local_values(&self) -> impl Iterator { self.files.values().filter_map(FileBackend::as_local) } fn local_iter_mut(&mut self) -> impl Iterator { self.files .iter_mut() .filter_map(|(id, backend)| match backend { FileBackend::Local(f) => Some((id, f)), FileBackend::Remote { .. } => None, }) } } /// Tracks which file paths belong to which repository roots, with O(1) repo cleanup checks. /// /// Encapsulates the path-to-repo mapping and repo path count together so callers /// cannot forget to maintain the count invariant. #[derive(Default)] struct RepoPathMappingState { /// Maps file path to its repository root for quick lookup during cleanup. path_to_repo: HashMap, /// Tracks how many distinct paths reference each repo root. repo_path_count: HashMap, } impl RepoPathMappingState { fn insert(&mut self, path: PathBuf, repo_root: PathBuf) { // Only increment count if this is a genuinely new path entry. if self.path_to_repo.insert(path, repo_root.clone()).is_none() { *self.repo_path_count.entry(repo_root).or_insert(0) += 1; } } fn remove(&mut self, path: &Path) -> Option<(PathBuf, bool)> { if let Some(repo_root) = self.path_to_repo.remove(path) { let mut unused_repo = true; if let Some(count) = self.repo_path_count.get_mut(&repo_root) { *count = count.saturating_sub(1); if *count == 0 { self.repo_path_count.remove(&repo_root); } else { unused_repo = false; } } Some((repo_root, unused_repo)) } else { None } } /// Returns all paths mapped to the given repository root. fn paths_for_repo(&self, repo_root: &Path) -> Vec { self.path_to_repo .iter() .filter(|(_, root)| root.as_path() == repo_root) .map(|(path, _)| path.clone()) .collect() } } pub struct FileModel { file_state: FileState, abort_handles: HashMap, watcher: ModelHandle, /// Maps repository root path to its subscription. One subscription per repo. repo_subscriptions: HashMap, repo_path_mapping: RepoPathMappingState, } impl FileModel { pub fn new(ctx: &mut ModelContext) -> Self { let watcher = ctx.add_model(|ctx| BulkFilesystemWatcher::new(Duration::from_millis(200), ctx)); ctx.subscribe_to_model(&watcher, |me, event, ctx| { me.handle_watcher_event(event, ctx); }); Self { watcher, file_state: FileState::default(), abort_handles: HashMap::new(), repo_subscriptions: HashMap::new(), repo_path_mapping: RepoPathMappingState::default(), } } #[cfg(feature = "test-util")] pub fn get_future_handle(&self, file_id: FileId) -> Option { self.abort_handles.get(&file_id).cloned() } fn handle_watcher_event( &mut self, event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { let updated_files = event.added_or_updated_set(); self.reload_file_paths(updated_files, ctx); } pub fn file_path(&self, file_id: FileId) -> Option { self.file_state .get_local(file_id) .and_then(|x| x.path.clone()) } /// Register a remote file path and return a `FileId`. /// /// The returned `FileId` can be used with `save()` and `delete()` which /// will dispatch to the remote backend via [`RemoteServerClient`]. pub fn register_remote_file(&mut self, host_id: HostId, path: StandardizedPath) -> FileId { let file_id = FileId::new(); self.file_state.insert_remote(file_id, host_id, path); file_id } /// Register a file path and immediately return a FileId without loading the file. /// This is useful when you need a FileId in a constructor but don't need to load the file. This will /// also not opt-in to receive file watcher updates. pub fn register_file_path( &mut self, file_path: &Path, subscribe_to_updates: bool, ctx: &mut ModelContext, ) -> FileId { let file_id = FileId::new(); let watcher_type = if subscribe_to_updates { // Try to use repository-level watching if available if let Some(repo_root) = self.get_or_create_repo_subscription(file_path, ctx) { self.repo_path_mapping .insert(file_path.to_path_buf(), repo_root); WatcherType::Repository } else { // Fallback to individual file watcher self.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( file_path, WatchFilter::accept_all(), RecursiveMode::Recursive, )); }); WatcherType::Individual } } else { WatcherType::None }; let local_file = LocalFile::new(file_path.to_owned(), watcher_type); self.file_state.insert_local(file_id, local_file); file_id } /// Open a file to get its content asynchronously. This also opts in to receiving file watcher updates. pub fn open( &mut self, file_path: &Path, subscribe_to_updates: bool, ctx: &mut ModelContext, ) -> FileId { let file_id = FileId::new(); // Determine watcher type before spawning async work let watcher_type = if subscribe_to_updates { // Try to use repository-level watching if available if let Some(repo_root) = self.get_or_create_repo_subscription(file_path, ctx) { self.repo_path_mapping .insert(file_path.to_path_buf(), repo_root); WatcherType::Repository } else { // Will register individual watcher after file loads WatcherType::Individual } } else { WatcherType::None }; let file_path_buf = file_path.to_owned(); let file_path_clone = file_path_buf.clone(); let use_individual_watcher = watcher_type == WatcherType::Individual; let future = ctx.spawn( async move { let contents = async_fs::read_to_string(&file_path_buf) .await .map_err(FileLoadError::from); (file_id, contents) }, move |me, (file_id, load_result), ctx| match load_result { Ok(content) => { let version = ContentVersion::new(); me.set_version(file_id, version); // Only register individual watcher if not using repo subscription if use_individual_watcher { me.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( &file_path_clone, WatchFilter::accept_all(), RecursiveMode::Recursive, )); }); } ctx.emit(FileModelEvent::FileLoaded { content, id: file_id, version, }); } Err(err) => { ctx.emit(FileModelEvent::FailedToLoad { id: file_id, error: Rc::new(err), }); } }, ); let local_file = LocalFile::new(file_path.to_owned(), watcher_type); self.file_state.insert_local(file_id, local_file); self.abort_handles.insert(file_id, future); file_id } pub async fn read_content_for_file(file_path: &Path) -> Result { if !Self::file_exists(file_path).await { return Err(FileLoadError::DoesNotExist); } async_fs::read_to_string(file_path) .await .map_err(FileLoadError::from) } /// Asynchronously reads specific lines from a file using BufReader. /// /// # Arguments /// * `file_path` - Path to the file to read /// * `line_numbers` - A list of 0-based line numbers to retrieve. Supports non-consecutive lines. /// /// # Returns /// A vector of (line_number, line_content) tuples for each requested line that exists. /// Lines that don't exist in the file are omitted from the result. pub async fn read_lines_async( file_path: &Path, line_numbers: Vec, ) -> Result, FileLoadError> { use std::collections::HashSet; if line_numbers.is_empty() { return Ok(Vec::new()); } if !Self::file_exists(file_path).await { return Err(FileLoadError::DoesNotExist); } let requested_lines: HashSet = line_numbers.iter().copied().collect(); let max_line = *line_numbers.iter().max().unwrap(); let file = async_fs::File::open(file_path) .await .map_err(FileLoadError::from)?; let reader = BufReader::new(file); let mut lines_stream = reader.lines(); let mut result = Vec::with_capacity(line_numbers.len()); let mut current_line = 0usize; while let Some(line_result) = lines_stream.next().await { if current_line > max_line { break; } if requested_lines.contains(¤t_line) { let line = line_result.map_err(FileLoadError::from)?; result.push((current_line, line)); } current_line += 1; } Ok(result) } /// Reads a text file by async-streaming its lines, respecting a byte budget /// and optional line ranges. Returns [`TextFileReadResult::NotText`] if the /// file is not valid UTF-8. /// /// **Line ending normalization**: `\n` and `\r\n` line endings are normalized /// to `\n` (LF) in the returned content. Classic Mac `\r`-only line endings /// are **not** recognized as line separators (matching `read_line()` behavior). /// A trailing newline at the end of the file is preserved so that /// round-tripping content through this reader and writing it back does not /// silently drop the final newline. /// /// This is a modified version of the loop that [`futures::io::BufReader::lines()`] /// uses internally (i.e. repeated `read_line()` calls with newline stripping), /// but additionally tracks whether each line was terminated by a newline so /// the accumulator can preserve the file's trailing newline. pub async fn read_text_file( path: &Path, max_bytes: usize, requested_ranges: &[Range], last_modified: Option, ) -> anyhow::Result { let file = match async_fs::File::open(path).await { Ok(file) => file, Err(e) => return Err(anyhow::anyhow!(e)), }; let mut reader = futures::io::BufReader::new(file); let file_name = path.to_string_lossy().to_string(); let mut accumulator = text_file_reader::TextFileAccumulator::new( file_name, last_modified, requested_ranges, max_bytes, ); // Use `read_line()` instead of `lines()` so we can detect whether each // line was terminated by a newline. `lines()` strips this information, // which caused trailing newlines to be silently dropped. let mut line_buf = String::new(); loop { line_buf.clear(); let bytes_read = match reader.read_line(&mut line_buf).await { Ok(n) => n, Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { // Not valid UTF-8. return Ok(TextFileReadResult::NotText); } Err(e) => return Err(anyhow::anyhow!(e)), }; if bytes_read == 0 { break; // EOF } // Strip the line terminator (`\n` or `\r\n`) and record whether // one was present. Note: `read_line()` only splits on `\n`, so // standalone `\r` (classic Mac) is not treated as a line separator. let has_newline = line_buf.ends_with('\n'); if has_newline { line_buf.pop(); } if line_buf.ends_with('\r') { line_buf.pop(); } accumulator.push_line(std::mem::take(&mut line_buf), has_newline); } let (segments, bytes_read) = accumulator.finalize(); Ok(TextFileReadResult::Segments { segments, bytes_read, }) } pub async fn read_file_as_binary(file_path: &Path) -> Result, FileLoadError> { if !Self::file_exists(file_path).await { return Err(FileLoadError::DoesNotExist); } async_fs::read(file_path).await.map_err(FileLoadError::from) } pub async fn file_exists(file_path: &Path) -> bool { async_fs::metadata(file_path).await.is_ok() } pub async fn create_file(file_path: &Path) -> Result<(), io::Error> { async_fs::File::create(file_path).await.map(|_| ()) } /// Ensures all parent directories of the given path exist, creating them if necessary. pub async fn ensure_parent_directories(path: &Path) -> Result<(), io::Error> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { async_fs::create_dir_all(parent).await?; } } Ok(()) } pub fn cancel(&mut self, file_id: FileId) { if let Some(future) = self.abort_handles.remove(&file_id) { future.abort() } } pub fn unsubscribe(&mut self, file_id: FileId, ctx: &mut ModelContext) { self.abort_handles.remove(&file_id); if let Some((FileBackend::Local(file), path_still_used)) = self.file_state.remove(file_id) { let path = file.path; let watcher_type = file.watcher_type; if let Some(ref path) = path { if !path_still_used { match watcher_type { WatcherType::Individual => { self.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.unregister_path(path.as_path())); }); } WatcherType::Repository => { if let Some((repo_root, unused_repo)) = self.repo_path_mapping.remove(path) { if unused_repo { self.unsubscribe_from_repo(&repo_root, ctx); } } } WatcherType::None => {} } } } } // Remote files have no watcher to clean up. } pub fn save( &mut self, file_id: FileId, content: String, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { let backend = self .file_state .get(file_id) .ok_or(FileSaveError::NoFilePath(file_id))?; match backend { FileBackend::Local(_) => { let file_path = self .file_path(file_id) .ok_or(FileSaveError::NoFilePath(file_id))?; ctx.spawn( async move { if let Err(err) = Self::ensure_parent_directories(&file_path).await { return Err(FileSaveError::IOError { error: err, path: file_path, }); } async_fs::write(&file_path, content).await.map_err(|err| { FileSaveError::IOError { error: err, path: file_path, } }) }, move |me, write_result: Result<(), FileSaveError>, ctx| { match write_result { Ok(_) => { me.set_version(file_id, version); ctx.emit(FileModelEvent::FileSaved { id: file_id, version, }) } Err(err) => ctx.emit(FileModelEvent::FailedToSave { id: file_id, error: Rc::new(err), }), }; }, ); } FileBackend::Remote { host_id, path } => { let client = Self::resolve_remote_client(host_id, ctx)?; let path = path.as_str().to_string(); let future = async move { client .write_file(path, content) .await .map_err(|e| e.to_string()) }; ctx.spawn( future, move |me, result: Result<(), String>, ctx| match result { Ok(()) => { me.set_version(file_id, version); ctx.emit(FileModelEvent::FileSaved { id: file_id, version, }); } Err(err) => { ctx.emit(FileModelEvent::FailedToSave { id: file_id, error: Rc::new(FileSaveError::RemoteError(err)), }); } }, ); } } Ok(()) } /// Renames a file and also saves its content. // TODO: refactor this against [`FileModel::save`]. pub fn rename_and_save( &mut self, file_id: FileId, new_path: PathBuf, content: String, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { let file_path = self .file_path(file_id) .ok_or(FileSaveError::NoFilePath(file_id))?; ctx.spawn( async move { // Make sure the file we're renaming exists. if let Err(err) = async_fs::metadata(&file_path).await { return Err(FileSaveError::IOError { error: err, path: file_path, }); } // Make sure the components of the path we're moving to exists. if let Err(err) = Self::ensure_parent_directories(&new_path).await { return Err(FileSaveError::IOError { error: err, path: new_path, }); } // Write the updated contents to the old path first. async_fs::write(&file_path, content).await.map_err(|err| { FileSaveError::IOError { error: err, path: file_path.clone(), } })?; // Now rename. async_fs::rename(&file_path, &new_path) .await .map_err(|err| FileSaveError::IOError { error: err, path: file_path.clone(), }) }, move |me, write_result: Result<(), FileSaveError>, ctx| { match write_result { Ok(_) => { me.set_version(file_id, version); ctx.emit(FileModelEvent::FileSaved { id: file_id, version, }) } Err(err) => ctx.emit(FileModelEvent::FailedToSave { id: file_id, error: Rc::new(err), }), }; }, ); Ok(()) } /// Deletes the specified file. pub fn delete( &mut self, file_id: FileId, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { let backend = self .file_state .get(file_id) .ok_or(FileSaveError::NoFilePath(file_id))?; match backend { FileBackend::Local(_) => { let file_path = self .file_path(file_id) .ok_or(FileSaveError::NoFilePath(file_id))?; ctx.spawn( async move { if let Err(err) = Self::ensure_parent_directories(&file_path).await { return Err(FileSaveError::IOError { error: err, path: file_path, }); } async_fs::remove_file(&file_path).await.map_err(|err| { FileSaveError::IOError { error: err, path: file_path, } }) }, move |me, delete_result: Result<(), FileSaveError>, ctx| { match delete_result { Ok(_) => { me.set_version(file_id, version); ctx.emit(FileModelEvent::FileSaved { id: file_id, version, }) } Err(err) => ctx.emit(FileModelEvent::FailedToSave { id: file_id, error: Rc::new(err), }), }; }, ); } FileBackend::Remote { host_id, path } => { let client = Self::resolve_remote_client(host_id, ctx)?; let path = path.as_str().to_string(); let future = async move { client.delete_file(path).await.map_err(|e| e.to_string()) }; ctx.spawn( future, move |me, result: Result<(), String>, ctx| match result { Ok(()) => { me.set_version(file_id, version); ctx.emit(FileModelEvent::FileSaved { id: file_id, version, }); } Err(err) => { ctx.emit(FileModelEvent::FailedToSave { id: file_id, error: Rc::new(FileSaveError::RemoteError(err)), }); } }, ); } } Ok(()) } /// Look up the `RemoteServerClient` for a given host at call time. fn resolve_remote_client( host_id: &HostId, ctx: &AppContext, ) -> Result, FileSaveError> { RemoteServerManager::as_ref(ctx) .client_for_host(host_id) .cloned() .ok_or_else(|| { FileSaveError::RemoteError(format!("Remote host {host_id} is not connected")) }) } pub fn set_version(&mut self, file_id: FileId, version: ContentVersion) { if let Some(backend) = self.file_state.get_mut(file_id) { backend.set_version(version); } } pub fn version(&self, file_id: FileId) -> Option { self.file_state.get(file_id).and_then(|b| b.version()) } /// Checks if a repository subscription exists for the given path, and creates one if needed. /// Returns the repository root path if a subscription exists or was created. fn get_or_create_repo_subscription( &mut self, file_path: &Path, ctx: &mut ModelContext, ) -> Option { // Check if we already have a subscription for a repo containing this path for repo_root in self.repo_subscriptions.keys() { if file_path.starts_with(repo_root) { return Some(repo_root.clone()); } } // Try to find a repository for this path let repository = DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(file_path, ctx)?; let repo_root = repository.as_ref(ctx).root_dir().to_local_path_lossy(); // Create a new subscription let (repository_update_tx, repository_update_rx) = async_channel::unbounded(); let start = repository.update(ctx, |repo, ctx| { repo.start_watching( Box::new(FileRepositorySubscriber { repository_update_tx, }), ctx, ) }); let subscriber_id = start.subscriber_id; let repo_root_for_handler = repo_root.clone(); let repository_for_handler = repository.clone(); ctx.spawn(start.registration_future, move |me, result, ctx| match result { Ok(()) => { log::info!( "FileModel subscribed to repository: {}", repo_root_for_handler.display() ); } Err(err) => { log::warn!( "Failed to subscribe to repository {}: {}, falling back to individual file watchers", repo_root_for_handler.display(), err ); repository_for_handler.update(ctx, |repo, ctx| { repo.stop_watching(subscriber_id, ctx); }); // Remove the subscription entry since it failed me.repo_subscriptions.remove(&repo_root_for_handler); // Fall back to individual file watchers for all files that were expecting this repo me.fallback_to_individual_watchers(&repo_root_for_handler, ctx); } }); // Set up the stream handler for repository updates let repo_root_for_stream = repo_root.clone(); ctx.spawn_stream_local( repository_update_rx, move |me, update, ctx| { me.handle_repository_update(&repo_root_for_stream, update, ctx); }, |_, _| {}, ); // Store the subscription (subscriber_id is available immediately; registration completion is async) self.repo_subscriptions.insert( repo_root.clone(), RepositorySubscription { repository, subscriber_id: Some(subscriber_id), }, ); Some(repo_root) } /// Handles file updates from a repository subscription. fn handle_repository_update( &mut self, _repo_root: &Path, update: RepositoryUpdate, ctx: &mut ModelContext, ) { if update.is_empty() { return; } // Collect paths that need content reloading: // - Added files that match tracked paths // - Moved files where the to_target matches a tracked path let mut paths_to_reload: HashSet = update .added_or_modified() .filter(|target| !target.is_ignored) .map(|target| target.path.clone()) .collect(); for to_target in update.moved.keys() { if !to_target.is_ignored { paths_to_reload.insert(to_target.path.clone()); } } // Reload content for all affected paths self.reload_file_paths(paths_to_reload, ctx); // Handle deleted files - log that the file's backing is gone for target in &update.deleted { if target.is_ignored { continue; } let has_file = self .file_state .local_values() .any(|file| file.path.as_ref() == Some(&target.path)); if has_file { log::info!( "File's backing was deleted, file is now orphaned: {}", target.path.display() ); } } // Handle moved/renamed files - log that the file's backing was moved (for from_target) for (to_target, from_target) in &update.moved { if from_target.is_ignored { continue; } let has_file = self .file_state .local_values() .any(|file| file.path.as_ref() == Some(&from_target.path)); if has_file { log::info!( "File's backing was moved from {} to {}, file is now orphaned", from_target.path.display(), to_target.path.display() ); } } } /// Reloads file content if the given path matches any tracked file. fn reload_file_paths(&mut self, file_paths: HashSet, ctx: &mut ModelContext) { // First filter to keep only the paths that are tracked. let matching_files: Vec = file_paths .into_iter() .filter_map(|path| { self.file_state .local_values() .any(|file| file.should_receive_update_for_path(&path)) .then_some(path) }) .collect(); if matching_files.is_empty() { return; } // Autoreload modified files. ctx.spawn( async move { let mut res = Vec::new(); for file_path in matching_files { if let Ok(content) = async_fs::read_to_string(&file_path).await { res.push((file_path, content)); } } res }, move |me, res, ctx| { for (file_path, content) in res { let mut emitted_event = false; for (file_id, file_state) in me.file_state.local_iter_mut() { // Only set the new version of a file if it has opt-in to receiving updates. if file_state.should_receive_update_for_path(&file_path) { let new_version = ContentVersion::new(); ctx.emit(FileModelEvent::FileUpdated { id: *file_id, content: content.clone(), base_version: file_state.version.expect("Version should be some"), new_version, }); emitted_event = true; file_state.version = Some(new_version); } } if !emitted_event { log::warn!( "{} is changed but there is no handler for the update event", file_path.display() ); } } }, ); } /// Falls back to individual file watchers for all files that were expecting to use the given repository. /// Called when a repository subscription fails. fn fallback_to_individual_watchers(&mut self, repo_root: &Path, ctx: &mut ModelContext) { let affected_paths = self.repo_path_mapping.paths_for_repo(repo_root); for path in affected_paths { self.repo_path_mapping.remove(&path); // Find the file(s) at this path and update their watcher type for (_, file) in self.file_state.local_iter_mut() { if file.path.as_ref() == Some(&path) && file.watcher_type == WatcherType::Repository { file.watcher_type = WatcherType::Individual; } } // Register individual file watcher self.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( &path, WatchFilter::accept_all(), RecursiveMode::Recursive, )); }); } } /// Checks if any files still reference the given repository, and unsubscribes if not. fn unsubscribe_from_repo(&mut self, repo_root: &Path, ctx: &mut ModelContext) { if let Some(subscription) = self.repo_subscriptions.remove(repo_root) { log::info!( "No more files in repository {}, unsubscribing", repo_root.display() ); if let Some(subscriber_id) = subscription.subscriber_id { subscription.repository.update(ctx, |repo, ctx| { repo.stop_watching(subscriber_id, ctx); }); } } } } impl Entity for FileModel { type Event = FileModelEvent; } impl SingletonEntity for FileModel {} /// Subscriber for repository file change events, forwarding updates to FileModel. struct FileRepositorySubscriber { repository_update_tx: Sender, } impl RepositorySubscriber for FileRepositorySubscriber { fn on_scan( &mut self, _repository: &Repository, _ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { // Files are already loaded when subscription starts, so we don't need to do anything. Box::pin(async {}) } fn on_files_updated( &mut self, _repository: &Repository, update: &RepositoryUpdate, _ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { let tx = self.repository_update_tx.clone(); let update = update.clone(); Box::pin(async move { let _ = tx.send(update).await; }) } } #[cfg(test)] #[path = "lib_test.rs"] mod tests;