Files
galaxy/app/src/code/global_buffer_model.rs
T

1196 lines
44 KiB
Rust

#![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<Buffer>,
}
impl BufferState {
pub fn new(file_id: FileId, buffer: ModelHandle<Buffer>) -> Self {
Self { file_id, buffer }
}
}
/// Tracks an active background diff parsing operation.
struct PendingDiffParse {
abort_handle: AbortHandle,
}
struct InternalBufferState {
buffer: WeakModelHandle<Buffer>,
base_content_version: Option<ContentVersion>,
/// 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<ContentVersion>,
/// 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<usize>,
/// Tracks any active background diff parsing for auto-reload.
pending_diff_parse: Option<PendingDiffParse>,
}
pub enum GlobalBufferModelEvent {
BufferLoaded {
file_id: FileId,
content_version: ContentVersion,
},
FailedToLoad {
file_id: FileId,
error: Rc<FileLoadError>,
},
BufferUpdatedFromFileEvent {
file_id: FileId,
success: bool,
content_version: ContentVersion,
},
FileSaved {
file_id: FileId,
},
FailedToSave {
file_id: FileId,
error: Rc<FileSaveError>,
},
}
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<PathBuf, FileId>,
buffers: HashMap<FileId, InternalBufferState>,
}
impl GlobalBufferModel {
pub fn new(_ctx: &mut ModelContext<Self>) -> 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<Self>) {
let ids_to_remove: HashSet<FileId> = 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<PathBuf> = 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<Self>) {
// 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<Self>,
) -> Option<ModelHandle<Buffer>> {
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<Self>,
) {
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<Buffer>,
new_content: &str,
base_version: ContentVersion,
new_version: ContentVersion,
ctx: &mut ModelContext<Self>,
) {
// 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<Self>,
) {
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<Self>) {
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<Self>,
) -> 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<Self>,
) -> 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<Self>,
) -> 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>) {
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<ContentVersion> {
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<Self>) {
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<Self>,
) -> Option<BufferState> {
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<Buffer>,
ctx: &mut ModelContext<Self>,
) -> 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<Buffer>,
base_content_version: Option<ContentVersion>,
initial_content_version: Option<ContentVersion>,
ctx: &mut ModelContext<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>,
) -> Option<ModelHandle<LspServerModel>> {
LspManagerModel::as_ref(ctx).server_for_path(path, ctx)
}
fn log_lsp_sync_debug(&self, path: &Path, message: String, ctx: &mut ModelContext<Self>) {
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<usize>,
ctx: &mut ModelContext<Self>,
) -> Option<Vec<(usize, String)>> {
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<Buffer>,
path: &Path,
buffer_version: BufferVersion,
ctx: &mut ModelContext<Self>,
) {
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<Self>) {
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<Self>,
) {
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<Buffer>,
deltas: &[PreciseDelta],
path: &Path,
buffer_version: BufferVersion,
previous_version: Option<usize>,
ctx: &mut ModelContext<Self>,
) {
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 {}