#![cfg_attr(not(feature = "local_fs"), allow(dead_code))] use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; use bimap::BiMap; use futures_util::stream::AbortHandle; use lsp::types::TextDocumentContentChangeEvent; use lsp::{LspManagerModel, LspServerLogLevel, LspServerModel}; use vec1::vec1; use warp_core::features::FeatureFlag; use warp_editor::content::buffer::Buffer; use warp_editor::content::diff::{text_diff, TextDiff}; use warp_editor::content::edit::PreciseDelta; use warp_editor::content::version::BufferVersion; use warp_util::content_version::ContentVersion; use warp_util::file::{FileId, FileLoadError, FileSaveError}; use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use lsp::LspManagerModelEvent; use warp_files::{FileModelEvent, FileModel}; use warp_editor::content::text::IndentBehavior; use warp_editor::content::text::IndentUnit; use warp_editor::content::buffer::EditOrigin; } } /// State for a shared buffer including the file ID and buffer handle. #[derive(Debug, Clone)] pub struct BufferState { pub file_id: FileId, pub buffer: ModelHandle, } impl BufferState { pub fn new(file_id: FileId, buffer: ModelHandle) -> Self { Self { file_id, buffer } } } /// Tracks an active background diff parsing operation. struct PendingDiffParse { abort_handle: AbortHandle, } struct InternalBufferState { buffer: WeakModelHandle, base_content_version: Option, /// Different from base_content_version, this is the first ever content version /// when the file is loaded in the app. We track this version so we don't submit /// a document updated event to LSP when the file is first loaded. (We do need to /// submit document updated if the file is auto-reloaded). initial_content_version: Option, /// Tracks the latest buffer version we've attempted to sync with LSP. /// Used to detect if previous versions were synced successfully. latest_buffer_version: Option, /// Tracks any active background diff parsing for auto-reload. pending_diff_parse: Option, } pub enum GlobalBufferModelEvent { BufferLoaded { file_id: FileId, content_version: ContentVersion, }, FailedToLoad { file_id: FileId, error: Rc, }, BufferUpdatedFromFileEvent { file_id: FileId, success: bool, content_version: ContentVersion, }, FileSaved { file_id: FileId, }, FailedToSave { file_id: FileId, error: Rc, }, } impl GlobalBufferModelEvent { pub fn file_id(&self) -> FileId { match self { GlobalBufferModelEvent::BufferLoaded { file_id, .. } | GlobalBufferModelEvent::FailedToLoad { file_id, .. } | GlobalBufferModelEvent::BufferUpdatedFromFileEvent { file_id, .. } | GlobalBufferModelEvent::FileSaved { file_id, .. } | GlobalBufferModelEvent::FailedToSave { file_id, .. } => *file_id, } } } /// Global singleton model for managing shared buffers across editors. /// /// This allows multiple editors to share the same buffer when editing the same file, /// enabling consistent content synchronization and more efficient memory usage. pub struct GlobalBufferModel { path_to_id: BiMap, buffers: HashMap, } impl GlobalBufferModel { pub fn new(_ctx: &mut ModelContext) -> Self { #[cfg(feature = "local_fs")] _ctx.subscribe_to_model(&FileModel::handle(_ctx), Self::handle_file_model_events); #[cfg(feature = "local_fs")] _ctx.subscribe_to_model( &LspManagerModel::handle(_ctx), Self::handle_lsp_manager_events, ); Self { path_to_id: BiMap::new(), buffers: HashMap::new(), } } /// Scan through all buffers and deallocate any that are no longer in use. pub fn remove_deallocated_buffers(&mut self, ctx: &mut ModelContext) { let ids_to_remove: HashSet = self .buffers .iter() .filter_map(|(id, state)| { if state.buffer.upgrade(ctx).is_none() { Some(*id) } else { None } }) .collect(); if ids_to_remove.is_empty() { return; } // Collect paths for didClose before removing entries. let paths_to_close: Vec = ids_to_remove .iter() .filter_map(|id| self.path_to_id.get_by_right(id).cloned()) .collect(); for path in &paths_to_close { self.close_document_with_lsp(path, ctx); } for id in &ids_to_remove { self.path_to_id.remove_by_right(id); } for id in ids_to_remove { self.buffers.remove(&id); #[cfg(feature = "local_fs")] { let file_model = FileModel::handle(ctx); file_model.update(ctx, |file_model, ctx| { file_model.cancel(id); file_model.unsubscribe(id, ctx); }); } } } pub fn buffer_loaded(&self, file_id: FileId) -> bool { self.buffers .get(&file_id) .map(|state| state.base_content_version.is_some()) .unwrap_or(false) } fn cleanup_file_id(&mut self, file_id: FileId, _ctx: &mut ModelContext) { // Send didClose before removing the entry. if let Some((path, _)) = self.path_to_id.remove_by_right(&file_id) { self.close_document_with_lsp(&path, _ctx); } self.buffers.remove(&file_id); #[cfg(feature = "local_fs")] { let file_model = FileModel::handle(_ctx); file_model.update(_ctx, |file_model, ctx| { file_model.cancel(file_id); file_model.unsubscribe(file_id, ctx); }); } } /// Returns the buffer handle if it is 1) still exists + active 2) loaded. fn buffer_handle_for_id( &mut self, file_id: FileId, ctx: &mut ModelContext, ) -> Option> { let state = self.buffers.get(&file_id)?; // If the buffer hasn't been loaded yet, don't return a model handle. if state.base_content_version.is_none() { log::info!("Cannot return handle for unloaded buffers"); return None; } match state.buffer.upgrade(ctx) { Some(handle) => Some(handle), None => { // Clean up deallocated buffers. self.cleanup_file_id(file_id, ctx); None } } } /// Once we finish reading the file's content from the disk, populate the buffer with the content. /// For initial load (is_loaded_from_file_system == true), this is synchronous. /// For auto-reload (is_loaded_from_file_system == false), this spawns a background task for diff computation. fn populate_buffer_with_read_content( &mut self, file_id: FileId, content: &str, base_version: ContentVersion, new_version: ContentVersion, is_initial_load: bool, ctx: &mut ModelContext, ) { let Some(state) = self.buffers.get_mut(&file_id) else { return; }; let Some(buffer) = state.buffer.upgrade(ctx) else { self.cleanup_file_id(file_id, ctx); log::warn!("Cannot populate buffer with content due to deallocated model handle"); return; }; if is_initial_load { // Initial load: use synchronous replace_all since there's nothing to preserve buffer.update(ctx, |buffer, ctx| { buffer.replace_all(content, ctx); buffer.set_version(new_version); }); state.base_content_version = Some(new_version); ctx.emit(GlobalBufferModelEvent::BufferLoaded { file_id, content_version: new_version, }); } else if FeatureFlag::IncrementalAutoReload.is_enabled() { // Auto-reload: spawn background task for diff computation Self::start_background_diff_parse( file_id, state, buffer, content, base_version, new_version, ctx, ); } else { // Fallback: synchronous replace_all (non-incremental) buffer.update(ctx, |buffer, ctx| { buffer.replace_all(content, ctx); buffer.set_version(new_version); }); state.base_content_version = Some(new_version); ctx.emit(GlobalBufferModelEvent::BufferUpdatedFromFileEvent { file_id, success: true, content_version: new_version, }); } } /// Spawns a background task to compute the diff between current buffer content and new content. /// On completion, applies the diff edits to the buffer. fn start_background_diff_parse( file_id: FileId, state: &mut InternalBufferState, buffer: ModelHandle, new_content: &str, base_version: ContentVersion, new_version: ContentVersion, ctx: &mut ModelContext, ) { // Abort any existing diff parse for this file if let Some(pending) = state.pending_diff_parse.take() { pending.abort_handle.abort(); } // Move owned strings to the background thread let old_text = buffer.as_ref(ctx).text().into_string(); let new_content_owned = new_content.to_string(); let handle = ctx.spawn( async move { text_diff(&old_text, &new_content_owned).await }, move |me, diff: TextDiff, ctx| { me.apply_diff_result(file_id, diff, base_version, new_version, ctx); }, ); // Store the abort handle so we can cancel if a newer update arrives state.pending_diff_parse = Some(PendingDiffParse { abort_handle: handle.abort_handle(), }); } /// Called when background diff parsing completes. Applies the diff edits to the buffer. fn apply_diff_result( &mut self, file_id: FileId, diff: TextDiff, base_version: ContentVersion, new_version: ContentVersion, ctx: &mut ModelContext, ) { let Some(state) = self.buffers.get_mut(&file_id) else { return; }; // Clear the pending diff parse state state.pending_diff_parse = None; let Some(buffer) = state.buffer.upgrade(ctx) else { self.cleanup_file_id(file_id, ctx); return; }; // Verify the buffer still matches the expected base version if !buffer.as_ref(ctx).version_match(&base_version) { log::info!("Buffer version changed during diff parsing, aborting apply"); ctx.emit(GlobalBufferModelEvent::BufferUpdatedFromFileEvent { file_id, success: false, content_version: base_version, }); return; } // Apply the diff edits buffer.update(ctx, |buffer, ctx| { if diff.is_empty() { // No actual changes to content, but still need to update version buffer.set_version(new_version); return; } let char_edits = diff.to_char_offset_edits(buffer); buffer.insert_at_char_offset_ranges(char_edits, new_version, ctx); }); state.base_content_version = Some(new_version); ctx.emit(GlobalBufferModelEvent::BufferUpdatedFromFileEvent { file_id, success: true, content_version: new_version, }); } #[cfg(feature = "local_fs")] fn handle_file_model_events(&mut self, event: &FileModelEvent, ctx: &mut ModelContext) { match event { FileModelEvent::FileLoaded { content, id, version, } => { // Only set the initial_content_version on first file load. if let Some(state) = self.buffers.get_mut(id) { state.initial_content_version = Some(*version); } // For initial load, base_version and new_version are the same self.populate_buffer_with_read_content(*id, content, *version, *version, true, ctx); } FileModelEvent::FailedToLoad { id, error } => { ctx.emit(GlobalBufferModelEvent::FailedToLoad { file_id: *id, error: error.clone(), }); } FileModelEvent::FileUpdated { id, content, base_version, new_version, } => { if let Some(buffer) = self.buffer_handle_for_id(*id, ctx) { if buffer.as_ref(ctx).version_match(base_version) { self.populate_buffer_with_read_content( *id, content, *base_version, *new_version, false, ctx, ); } else { // Buffer version doesn't match the event's base_version. // Check if the buffer has no user edits (matches our internal // base_content_version). If so, it's safe to start a fresh // diff parse from the actual buffer version to the new content. let internal_base_version = self .buffers .get(id) .and_then(|state| state.base_content_version); let has_no_user_edits = internal_base_version .is_some_and(|v| buffer.as_ref(ctx).version_match(&v)); if has_no_user_edits { // No user edits: safe to reload from the actual buffer // version. This handles both: log::info!( "Starting fresh diff parse for file update (no user edits, \ internal base {:?}, event base {:?})", internal_base_version, *base_version ); let actual_version = buffer.as_ref(ctx).version(); self.populate_buffer_with_read_content( *id, content, actual_version, *new_version, false, ctx, ); } else { log::info!("Not updating global buffer due to version conflict"); // Abort any pending diff parse since the buffer has // user edits that we must not overwrite. if let Some(state) = self.buffers.get_mut(id) { if let Some(pending) = state.pending_diff_parse.take() { pending.abort_handle.abort(); } } if internal_base_version != Some(*base_version) { log::warn!( "Internal global buffer base version {:?} mismatches file model base version {:?}", internal_base_version, *base_version ); } ctx.emit(GlobalBufferModelEvent::BufferUpdatedFromFileEvent { file_id: *id, success: false, content_version: *base_version, }); } } } } FileModelEvent::FileSaved { id, version } => { // Make sure base content version is updated after a save is performed. // This avoids us flagging the incoming update from file watcher as conflict changes. if let Some(state) = self.buffers.get_mut(id) { state.base_content_version = Some(*version); } ctx.emit(GlobalBufferModelEvent::FileSaved { file_id: *id }); } FileModelEvent::FailedToSave { id, error } => { ctx.emit(GlobalBufferModelEvent::FailedToSave { file_id: *id, error: error.clone(), }); } } } /// Save the content of a tracked buffer to disk via FileModel. #[cfg(feature = "local_fs")] pub fn save( &self, file_id: FileId, content: String, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { FileModel::handle(ctx).update(ctx, |file_model, ctx| { file_model.save(file_id, content, version, ctx) }) } /// Rename a file and save its content via FileModel. #[cfg(feature = "local_fs")] pub fn rename_and_save( &self, file_id: FileId, new_path: PathBuf, content: String, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { FileModel::handle(ctx).update(ctx, |file_model, ctx| { file_model.rename_and_save(file_id, new_path, content, version, ctx) }) } /// Delete a file via FileModel. #[cfg(feature = "local_fs")] pub fn delete( &self, file_id: FileId, version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { FileModel::handle(ctx).update(ctx, |file_model, ctx| { file_model.delete(file_id, version, ctx) }) } /// Remove a tracked buffer, cleaning up FileModel and LSP state. /// Used when a new file is deleted before ever being saved to a permanent location. pub fn remove(&mut self, file_id: FileId, ctx: &mut ModelContext) { self.cleanup_file_id(file_id, ctx); } /// Look up the file path for a tracked buffer. pub fn file_path(&self, file_id: FileId) -> Option<&Path> { self.path_to_id .get_by_right(&file_id) .map(|path| path.as_path()) } /// Get the base content version (last known on-disk version) for a tracked buffer. pub fn base_version(&self, file_id: FileId) -> Option { self.buffers .get(&file_id) .and_then(|state| state.base_content_version) } /// Discard any in progress changes and reload the buffer with the canonical version from the file system. #[cfg(feature = "local_fs")] pub fn discard_unsaved_changes(&mut self, path: &PathBuf, ctx: &mut ModelContext) { if let Some(id) = self.path_to_id.get_by_left(path).cloned() { let path_clone = path.clone(); ctx.spawn( async move { FileModel::read_content_for_file(&path_clone).await }, move |me, content, ctx| match content { Ok(content) => { // Consider this reload as a "new" version. This prevents any race condition when there is another // auto-reload while we are reading out the latest content. let new_version = ContentVersion::new(); // For discard, we get the current base version from the buffer state let base_version = me .buffers .get(&id) .and_then(|state| { state.buffer.upgrade(ctx).map(|b| b.as_ref(ctx).version()) }) .unwrap_or(new_version); FileModel::handle(ctx).update(ctx, |file_model, _ctx| { file_model.set_version(id, new_version); }); me.populate_buffer_with_read_content( id, &content, base_version, new_version, false, ctx, ); } Err(e) => ctx.emit(GlobalBufferModelEvent::FailedToLoad { file_id: id, error: e.into(), }), }, ); } } /// Remap an existing buffer from `old_file_id` to a new path, preserving the buffer /// content and unsaved edits. Sends didClose for the old path and re-registers the /// new path with FileModel and LSP. /// /// Used for file rename. #[cfg(feature = "local_fs")] pub fn rename( &mut self, old_file_id: FileId, new_path: PathBuf, ctx: &mut ModelContext, ) -> Option { let old_state = self.buffers.remove(&old_file_id)?; let buffer = old_state.buffer.upgrade(ctx)?; // Send didClose for the old path and remove the mapping. // Internal state cleanup is synchronous; only the LSP didClose notification // is dispatched asynchronously (with a no-op callback), so there is no race // between state removal and the close completing. if let Some((old_path, _)) = self.path_to_id.remove_by_right(&old_file_id) { self.close_document_with_lsp(&old_path, ctx); } // Cancel + unsubscribe old FileId from FileModel. let file_model = FileModel::handle(ctx); file_model.update(ctx, |file_model, ctx| { file_model.cancel(old_file_id); file_model.unsubscribe(old_file_id, ctx); }); Some(self.register_buffer_for_path( new_path, buffer, old_state.base_content_version, old_state.initial_content_version, ctx, )) } /// Adopt an existing buffer under a new path without reading from disk. /// Used by `save_as` to register a newly-created file with GlobalBufferModel. #[cfg(feature = "local_fs")] pub fn register( &mut self, path: PathBuf, buffer: ModelHandle, ctx: &mut ModelContext, ) -> BufferState { let buffer_version = buffer.as_ref(ctx).version(); self.register_buffer_for_path( path, buffer, Some(buffer_version), Some(buffer_version), ctx, ) } /// Shared helper: register `buffer` under `path` with FileModel, subscribe to /// buffer events for LSP sync, store internal state, and open the document with LSP. #[cfg(feature = "local_fs")] fn register_buffer_for_path( &mut self, path: PathBuf, buffer: ModelHandle, base_content_version: Option, initial_content_version: Option, ctx: &mut ModelContext, ) -> BufferState { // If a buffer is already registered for this path, clean up the old entry // to avoid orphaning the previous FileId in `self.buffers`. if let Some(old_file_id) = self.path_to_id.get_by_left(&path).copied() { self.cleanup_file_id(old_file_id, ctx); } let buffer_version = buffer.as_ref(ctx).version(); let file_id = FileModel::handle(ctx).update(ctx, |file_model, ctx| { let id = file_model.register_file_path(&path, true, ctx); file_model.set_version(id, buffer_version); id }); self.path_to_id.insert(path.clone(), file_id); self.buffers.insert( file_id, InternalBufferState { buffer: buffer.downgrade(), base_content_version, initial_content_version, latest_buffer_version: None, pending_diff_parse: None, }, ); // Unsubscribe any existing buffer subscription (e.g. from a previous path) // before subscribing with the new path. ctx.unsubscribe_from_model(&buffer); // Subscribe to buffer events for LSP sync. let path_clone = path.clone(); ctx.subscribe_to_model(&buffer, move |me, event, ctx| { use warp_editor::content::buffer::BufferEvent; let Some(state) = me.buffers.get(&file_id) else { return; }; let Some(initial_version) = state.initial_content_version else { return; }; let Some(buffer) = state.buffer.upgrade(ctx) else { return; }; if let BufferEvent::ContentChanged { delta, buffer_version, origin, .. } = event { let version_matches_initial = buffer.as_ref(ctx).version_match(&initial_version); let fid = me.path_to_id.get_by_left(&path_clone).cloned(); let previous_version = fid .and_then(|id| me.buffers.get(&id)) .and_then(|state| state.latest_buffer_version); if let Some(id) = fid { if let Some(state) = me.buffers.get_mut(&id) { state.latest_buffer_version = Some(buffer_version.as_usize()); } } if matches!(origin, EditOrigin::SystemEdit) && version_matches_initial { me.open_or_sync_document_with_lsp(buffer, &path_clone, *buffer_version, ctx); return; } me.notify_lsp_of_content_change( buffer, &delta.precise_deltas, &path_clone, *buffer_version, previous_version, ctx, ); } }); // Open the document with LSP. let buffer_ver = buffer.as_ref(ctx).buffer_version(); self.open_or_sync_document_with_lsp(buffer.clone(), &path, buffer_ver, ctx); BufferState::new(file_id, buffer) } /// Open a buffer for the given file path. /// /// If a buffer already exists for this path and is loaded, returns the existing BufferState. /// If no buffer exists, creates a new Buffer and BufferState using FileModel. /// File system updates are automatically subscribed to for all buffers. /// /// # Arguments /// * `path` - The file path to open /// * `ctx` - The model context for creating new buffers if needed /// /// # Returns /// Returns the BufferState for the requested file path. #[cfg(feature = "local_fs")] pub fn open(&mut self, path: PathBuf, ctx: &mut ModelContext) -> BufferState { if let Some(id) = self.path_to_id.get_by_left(&path).cloned() { debug_assert!(self.buffers.contains_key(&id)); if let Some(state) = self.buffers.get(&id) { if let Some(handle) = state.buffer.upgrade(ctx) { // Only emit buffer loaded if the base content version is set. if state.base_content_version.is_some() { ctx.emit(GlobalBufferModelEvent::BufferLoaded { file_id: id, content_version: handle.as_ref(ctx).version(), }); } return BufferState::new(id, handle.clone()); } } } self.create_new_buffer(&path, ctx) } #[cfg(feature = "local_fs")] fn create_new_buffer(&mut self, path: &Path, ctx: &mut ModelContext) -> BufferState { // Open file through FileModel to get FileId // Always subscribe to updates for GlobalBufferModel created buffers let file_id = FileModel::handle(ctx).update(ctx, |file_model, ctx| file_model.open(path, true, ctx)); // Create new buffer let buffer = ctx.add_model(|_| { // This sets the default indentation behavior. The editor will override this if it can load the grammar config // for the given file path. Buffer::new(Box::new(|_, _| { IndentBehavior::TabIndent(IndentUnit::Space(4)) })) }); let path_clone = path.to_path_buf(); ctx.subscribe_to_model(&buffer, move |me, event, ctx| { use warp_editor::content::buffer::BufferEvent; let Some(state) = me.buffers.get(&file_id) else { me.log_lsp_sync_debug( &path_clone, format!( "lsp-sync: ContentChanged SKIPPED file={} reason=buffer_state_missing", path_clone.display() ), ctx, ); return; }; let Some(initial_version) = state.initial_content_version else { me.log_lsp_sync_debug( &path_clone, format!( "lsp-sync: ContentChanged SKIPPED file={} reason=initial_version_not_set", path_clone.display() ), ctx, ); return; }; let Some(buffer) = state.buffer.upgrade(ctx) else { me.log_lsp_sync_debug( &path_clone, format!( "lsp-sync: ContentChanged SKIPPED file={} reason=buffer_handle_deallocated", path_clone.display() ), ctx, ); return; }; if let BufferEvent::ContentChanged { delta, buffer_version, origin, .. } = event { let version_matches_initial = buffer.as_ref(ctx).version_match(&initial_version); // Read the previous latest_buffer_version before updating it. // This is needed to determine if we need a full sync later. let file_id = me.path_to_id.get_by_left(&path_clone).cloned(); let previous_version = file_id .and_then(|id| me.buffers.get(&id)) .and_then(|state| state.latest_buffer_version); // Always update the latest buffer version when we receive a ContentUpdated event, // even if we early return. This ensures we track versioning correctly. if let Some(id) = file_id { if let Some(state) = me.buffers.get_mut(&id) { state.latest_buffer_version = Some(buffer_version.as_usize()); } } // If this is a system edit AND the current buffer version matches the initial version // that came from file loading, this is the initial buffer population. Instead of // relying on the editor view to send didOpen, we handle it here to ensure the LSP // document lifecycle stays in sync with the buffer lifecycle. if matches!(origin, EditOrigin::SystemEdit) && version_matches_initial { me.open_or_sync_document_with_lsp(buffer, &path_clone, *buffer_version, ctx); return; } me.notify_lsp_of_content_change( buffer, &delta.precise_deltas, &path_clone, *buffer_version, previous_version, ctx, ); } }); self.path_to_id.insert(path.to_path_buf(), file_id); self.buffers.insert( file_id, InternalBufferState { buffer: buffer.downgrade(), base_content_version: None, initial_content_version: None, latest_buffer_version: None, pending_diff_parse: None, }, ); BufferState::new(file_id, buffer) } fn lsp_server_for_path( &self, path: &Path, ctx: &mut ModelContext, ) -> Option> { LspManagerModel::as_ref(ctx).server_for_path(path, ctx) } fn log_lsp_sync_debug(&self, path: &Path, message: String, ctx: &mut ModelContext) { if cfg!(debug_assertions) { if let Some(server) = self.lsp_server_for_path(path, ctx) { server .as_ref(ctx) .log_to_server_log(LspServerLogLevel::Info, message); } } } /// Attempts to retrieve specific lines from an in-memory buffer for the given file path. /// Returns `Some(Vec<(usize, String)>)` if the file is loaded in a buffer, `None` otherwise. /// /// This is a fast, synchronous operation that avoids disk I/O. /// /// # Arguments /// * `path` - Path to the file /// * `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 buffer are omitted from the result. pub fn get_lines_for_file( &mut self, path: &Path, line_numbers: Vec, ctx: &mut ModelContext, ) -> Option> { use warp_editor::content::text::LineCount; if line_numbers.is_empty() { return Some(Vec::new()); } let file_id = self.path_to_id.get_by_left(path)?; let buffer = self.buffer_handle_for_id(*file_id, ctx)?; let buffer_ref = buffer.as_ref(ctx); let total_lines = (buffer_ref.max_point().row + 1) as usize; let mut lines = Vec::with_capacity(line_numbers.len()); for line_idx in line_numbers { if line_idx >= total_lines { continue; } // Convert 0-based line index to 1-based LineCount let line_count = LineCount::from(line_idx + 1); let line_start = buffer_ref.line_start(line_count); let line_end = buffer_ref.line_end(line_count); let line_text = buffer_ref.text_in_range(line_start..line_end).into_string(); lines.push((line_idx, line_text)); } Some(lines) } /// Opens or resyncs a document with the LSP server. /// - If the LSP doesn't have the document open yet: sends `didOpen` /// - If the LSP already has the document open (buffer recreation): sends a full-content `didChange` fn open_or_sync_document_with_lsp( &mut self, buffer: ModelHandle, path: &Path, buffer_version: BufferVersion, ctx: &mut ModelContext, ) { let path_buf = path.to_path_buf(); let current_version = buffer_version.as_usize(); let Some(lsp_server) = &self.lsp_server_for_path(path, ctx) else { return; }; if !lsp_server.as_ref(ctx).is_ready_for_requests() { return; }; let content = buffer.as_ref(ctx).text_with_line_ending().into_string(); let lsp_already_has_document = lsp_server .as_ref(ctx) .last_synced_version(&path_buf) .ok() .flatten() .is_some(); if lsp_already_has_document { // Buffer was recreated but LSP still has the document open. // Send a full-content didChange to resync. lsp_server.as_ref(ctx).log_to_server_log( LspServerLogLevel::Info, format!( "didChange -> server: RESYNC full-content file={} send_version={current_version}", path.display() ), ); let content_changed_events = vec![TextDocumentContentChangeEvent { range: None, text: content, }]; let Ok(sync_future) = lsp_server.as_ref(ctx).did_change_document( path_buf, current_version.into(), content_changed_events, ) else { log::warn!("Failed to resync document with LSP server"); return; }; ctx.spawn(sync_future, |_, _, _| {}); } else { // First time opening this document with the LSP. self.log_lsp_sync_debug( path, format!( "lsp-sync: didOpen from GlobalBufferModel file={} version={current_version}", path.display() ), ctx, ); let Ok(open_future) = lsp_server .as_ref(ctx) .did_open_document(path_buf, content, current_version) else { log::warn!("Failed to open document with LSP server"); return; }; ctx.spawn(open_future, |_, _, _| {}); } } /// Sends `didClose` to the LSP server for the given path, if the document is open. fn close_document_with_lsp(&mut self, path: &Path, ctx: &mut ModelContext) { let Some(lsp_server) = self.lsp_server_for_path(path, ctx) else { return; }; let path_buf = path.to_path_buf(); if !lsp_server .as_ref(ctx) .document_is_open(&path_buf) .is_ok_and(|is_open| is_open) { return; }; self.log_lsp_sync_debug( path, format!( "lsp-sync: didClose from GlobalBufferModel file={}", path.display() ), ctx, ); let Ok(close_future) = lsp_server.as_ref(ctx).did_close_document(path_buf) else { log::warn!("Failed to close document with LSP server"); return; }; ctx.spawn(close_future, |_, _, _| {}); } /// When an LSP server starts, open all loaded buffers that match its workspace path. #[cfg(feature = "local_fs")] fn handle_lsp_manager_events( &mut self, event: &LspManagerModelEvent, ctx: &mut ModelContext, ) { let LspManagerModelEvent::ServerStarted(workspace_path) = event else { return; }; // Collect (path, buffer_handle, version) for all loaded buffers under this workspace. let buffers_to_open: Vec<_> = self .path_to_id .iter() .filter(|(path, _)| path.starts_with(workspace_path)) .filter_map(|(path, id)| { let state = self.buffers.get(id)?; // Only open buffers that have been loaded (have content). state.base_content_version.as_ref()?; let buffer = state.buffer.upgrade(ctx)?; let version = buffer.as_ref(ctx).buffer_version(); Some((path.clone(), buffer, version)) }) .collect(); for (path, buffer, version) in buffers_to_open { self.open_or_sync_document_with_lsp(buffer, &path, version, ctx); } } fn notify_lsp_of_content_change( &mut self, buffer: ModelHandle, deltas: &[PreciseDelta], path: &Path, buffer_version: BufferVersion, previous_version: Option, ctx: &mut ModelContext, ) { let path_buf = path.to_path_buf(); let current_version = buffer_version.as_usize(); let Some(lsp_server) = &self.lsp_server_for_path(path, ctx) else { return; }; if !lsp_server.as_ref(ctx).is_ready_for_requests() { return; }; // Check if the previous version was successfully synced. // If not, we need to fallback to syncing the full buffer content. let last_synced = lsp_server .as_ref(ctx) .last_synced_version(&path_buf) .ok() .flatten(); // If we have a previous version that wasn't synced, we need to do a full sync. let needs_full_sync = previous_version.is_some_and(|prev| { last_synced.is_none() || last_synced.is_some_and(|synced| synced < prev) }); let deltas_len = deltas.len(); if needs_full_sync { lsp_server.as_ref(ctx).log_to_server_log( LspServerLogLevel::Info, format!( "didChange -> server: FALLBACK full-sync file={} send_version={current_version} prev_version={previous_version:?} last_synced={last_synced:?} deltas={deltas_len}", path.display() ), ); } else { lsp_server.as_ref(ctx).log_to_server_log( LspServerLogLevel::Debug, format!( "didChange -> server: file={} send_version={current_version} prev_version={previous_version:?} last_synced={last_synced:?} deltas={deltas_len}", path.display() ), ); } let line_ending_mode = buffer.as_ref(ctx).line_ending_mode(); let content_changed_events = if needs_full_sync { // Send the full buffer content without a range. vec![TextDocumentContentChangeEvent { range: None, text: buffer.as_ref(ctx).text_with_line_ending().into_string(), }] } else { deltas .iter() .map(|delta| { let start = lsp::types::Location { line: delta.replaced_points.start.row.saturating_sub(1) as usize, column: delta.replaced_points.start.column as usize, }; let end = lsp::types::Location { line: delta.replaced_points.end.row.saturating_sub(1) as usize, column: delta.replaced_points.end.column as usize, }; TextDocumentContentChangeEvent { range: Some(lsp::types::Range { start, end }), text: buffer .as_ref(ctx) .text_in_ranges(vec1![delta.resolved_range.clone()], line_ending_mode) .into_string(), } }) .collect() }; let Ok(sync_future) = lsp_server.as_ref(ctx).did_change_document( path_buf, current_version.into(), content_changed_events, ) else { log::warn!("Failed to sync document with LSP server"); return; }; ctx.spawn(sync_future, |_, _, _| {}); } } impl Entity for GlobalBufferModel { type Event = GlobalBufferModelEvent; } impl SingletonEntity for GlobalBufferModel {}