diff --git a/crates/lsp/src/client.rs b/crates/lsp/src/client.rs index 619c5b9a9..473ed3f74 100644 --- a/crates/lsp/src/client.rs +++ b/crates/lsp/src/client.rs @@ -123,6 +123,38 @@ pub struct LspClient { } impl LspClient { + pub(crate) fn text_document_sync_kind(&self) -> Option { + self + .capabilities + .lock() + .unwrap() + .as_ref() + .and_then(|capabilities| capabilities.text_document_sync.as_ref()) + .and_then(|capability| match capability { + TextDocumentSyncCapability::Kind(kind) => Some(*kind), + TextDocumentSyncCapability::Options(options) => options.change, + }) + } + + pub(crate) fn should_include_text_on_save(&self) -> bool { + self + .capabilities + .lock() + .unwrap() + .as_ref() + .and_then(|capabilities| capabilities.text_document_sync.as_ref()) + .and_then(|capability| match capability { + TextDocumentSyncCapability::Kind(_) => None, + TextDocumentSyncCapability::Options(options) => options.save.as_ref(), + }) + .is_some_and(|save| match save { + TextDocumentSyncSaveOptions::Supported(_) => false, + TextDocumentSyncSaveOptions::SaveOptions(options) => { + options.include_text.unwrap_or(false) + } + }) + } + pub async fn start( server_path: PathBuf, args: Vec, diff --git a/crates/lsp/src/document_sync.rs b/crates/lsp/src/document_sync.rs new file mode 100644 index 000000000..9641c719c --- /dev/null +++ b/crates/lsp/src/document_sync.rs @@ -0,0 +1,790 @@ +use anyhow::{Context, Result, bail}; +use lsp_types::{Position, Range, TextDocumentContentChangeEvent}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, VecDeque}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentChange { + pub range_offset: usize, + pub range_length: usize, + pub text: String, + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentChangeBatch { + pub model_session_id: String, + pub model_version_id: i64, + #[serde(default)] + pub changes: Vec, + #[serde(default)] + pub is_eol_change: bool, + #[serde(default)] + pub is_flush: bool, + pub full_content: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum SyncMode { + None, + Full, + Incremental, +} + +/// Late batches from a replaced Monaco model are refused by session id; only the most recent +/// ones can still be in flight, so older ids are forgotten. +const MAX_RETIRED_MODEL_SESSIONS: usize = 16; + +#[derive(Debug)] +struct DocumentSession { + content: String, + line_starts: Vec, + model_session_id: Option, + retired_model_sessions: VecDeque, + model_version_id: i64, + lsp_version: i32, + pending_changes: Vec, + /// The server needs the whole document on the next emission. The text is read from `content` + /// then, so it is never copied while it waits. + pending_full_replacement: bool, + generation: u64, + sync_mode: SyncMode, + epoch: u64, + emission_lock: Arc>, +} + +pub(crate) struct PendingDocumentChanges { + pub version: i32, + pub changes: Vec, + full_replacement: bool, +} + +impl PendingDocumentChanges { + /// The changes to put in a `didChange` request. A full replacement is handed over rather than + /// copied, since a failed send only needs to know that the document is due in full again. + pub fn take_changes_for_send(&mut self) -> Vec { + if self.full_replacement { + std::mem::take(&mut self.changes) + } else { + self.changes.clone() + } + } +} + +#[derive(Clone)] +pub(crate) struct DocumentSessions { + inner: Arc>>, + next_epoch: Arc, +} + +impl Default for DocumentSessions { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + next_epoch: Arc::new(AtomicU64::new(1)), + } + } +} + +impl DocumentSessions { + pub fn open(&self, file_path: &str, content: String, sync_mode: SyncMode) { + let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); + let line_starts = collect_line_starts(&content); + self.inner.lock().unwrap().insert( + file_path.to_string(), + DocumentSession { + content, + line_starts, + model_session_id: None, + retired_model_sessions: VecDeque::new(), + model_version_id: 0, + lsp_version: 1, + pending_changes: Vec::new(), + pending_full_replacement: false, + generation: 0, + sync_mode, + epoch, + emission_lock: Arc::new(Mutex::new(())), + }, + ); + } + + pub fn close(&self, file_path: &str) { + self.inner.lock().unwrap().remove(file_path); + } + + #[cfg(test)] + pub fn queue(&self, file_path: &str, batch: DocumentChangeBatch) -> Result<(u64, u64, i32)> { + self.queue_many(file_path, vec![batch]) + } + + /// Applies every batch or none of them. The document is edited in place and an undo log + /// restores it if a later batch is invalid, so a keystroke never copies the whole file. + pub fn queue_many( + &self, + file_path: &str, + batches: Vec, + ) -> Result<(u64, u64, i32)> { + let mut sessions = self.inner.lock().unwrap(); + let session = sessions + .get_mut(file_path) + .context("No open LSP document session for this file")?; + let mut transaction = Transaction::begin(session); + for batch in batches { + if let Err(error) = apply_batch(session, &mut transaction, batch) { + transaction.rollback(session); + return Err(error); + } + } + session.generation = session.generation.wrapping_add(1); + Ok(( + session.epoch, + session.generation, + session.lsp_version.saturating_add(1), + )) + } + + #[cfg(test)] + pub fn flush(&self, file_path: &str) -> Option { + let mut sessions = self.inner.lock().unwrap(); + let session = sessions.get_mut(file_path)?; + take_pending(session) + } + + pub fn emit_pending( + &self, + file_path: &str, + expected: Option<(u64, u64)>, + emit: impl FnOnce(&mut PendingDocumentChanges) -> Result<()>, + ) -> Result<()> { + let (epoch, emission_lock) = { + let sessions = self.inner.lock().unwrap(); + let Some(session) = sessions.get(file_path) else { + return Ok(()); + }; + (session.epoch, Arc::clone(&session.emission_lock)) + }; + let _emission_guard = emission_lock.lock().unwrap(); + let pending = { + let mut sessions = self.inner.lock().unwrap(); + let Some(session) = sessions.get_mut(file_path) else { + return Ok(()); + }; + if session.epoch != epoch + || expected.is_some_and(|(expected_epoch, generation)| { + expected_epoch != session.epoch || generation != session.generation + }) + { + return Ok(()); + } + take_pending(session) + }; + if let Some(mut pending) = pending + && let Err(error) = emit(&mut pending) + { + let mut sessions = self.inner.lock().unwrap(); + if let Some(session) = sessions.get_mut(file_path) + && session.epoch == epoch + { + session.lsp_version = pending.version.saturating_sub(1); + if pending.full_replacement { + session.pending_changes.clear(); + session.pending_full_replacement = true; + } else if !session.pending_full_replacement { + let mut restored = pending.changes; + restored.append(&mut session.pending_changes); + session.pending_changes = restored; + } + } + return Err(error); + } + Ok(()) + } + + pub fn content(&self, file_path: &str) -> Option { + self + .inner + .lock() + .unwrap() + .get(file_path) + .map(|session| session.content.clone()) + } +} + +enum UndoStep { + Edit { + start: usize, + inserted_len: usize, + removed: String, + }, + Replace { + previous: String, + }, +} + +/// What `queue_many` needs to put a session back after an invalid batch. Replaced documents are +/// moved in here rather than copied, and small edits keep only the text they removed. +struct Transaction { + undo: Vec, + model_session_id: Option, + retired_model_sessions: VecDeque, + model_version_id: i64, + pending_len: usize, + pending_full_replacement: bool, + replaced_pending: Option>, +} + +impl Transaction { + fn begin(session: &DocumentSession) -> Self { + Self { + undo: Vec::new(), + model_session_id: session.model_session_id.clone(), + retired_model_sessions: session.retired_model_sessions.clone(), + model_version_id: session.model_version_id, + pending_len: session.pending_changes.len(), + pending_full_replacement: session.pending_full_replacement, + replaced_pending: None, + } + } + + fn rollback(self, session: &mut DocumentSession) { + if self.undo.is_empty() && self.replaced_pending.is_none() { + session.pending_changes.truncate(self.pending_len); + return; + } + for step in self.undo.into_iter().rev() { + match step { + UndoStep::Edit { + start, + inserted_len, + removed, + } => session + .content + .replace_range(start..start + inserted_len, &removed), + UndoStep::Replace { previous } => session.content = previous, + } + } + session.line_starts = collect_line_starts(&session.content); + session.model_session_id = self.model_session_id; + session.retired_model_sessions = self.retired_model_sessions; + session.model_version_id = self.model_version_id; + let mut pending = self + .replaced_pending + .unwrap_or_else(|| std::mem::take(&mut session.pending_changes)); + pending.truncate(self.pending_len); + session.pending_changes = pending; + session.pending_full_replacement = self.pending_full_replacement; + } +} + +fn apply_batch( + session: &mut DocumentSession, + transaction: &mut Transaction, + batch: DocumentChangeBatch, +) -> Result<()> { + let replacement = batch.is_flush || batch.is_eol_change; + if !replacement + && session.model_session_id.as_deref() == Some(&batch.model_session_id) + && batch.model_version_id <= session.model_version_id + { + bail!("Stale LSP document change batch"); + } + if !replacement + && session + .retired_model_sessions + .contains(&batch.model_session_id) + { + bail!("Change belongs to a retired LSP model session"); + } + + if replacement { + let next_content = batch + .full_content + .context("Full document content is required for replacement changes")?; + session.line_starts = collect_line_starts(&next_content); + let previous = std::mem::replace(&mut session.content, next_content); + transaction.undo.push(UndoStep::Replace { previous }); + let pending = std::mem::take(&mut session.pending_changes); + if transaction.replaced_pending.is_none() { + transaction.replaced_pending = Some(pending); + } + session.pending_full_replacement = true; + return Ok(()); + } + + if batch.changes.is_empty() { + bail!("Incremental document change batch is empty"); + } + + let mut changes = batch.changes; + // Monaco ranges within one event refer to the same pre-edit document. Applying + // them from the end keeps every earlier UTF-16 offset and position valid. + changes.sort_by_key(|change| std::cmp::Reverse(change.range_offset)); + for pair in changes.windows(2) { + if pair[1].range_offset.saturating_add(pair[1].range_length) > pair[0].range_offset { + bail!("Overlapping changes in one Monaco event are invalid"); + } + } + for change in changes { + let undo = apply_change(&mut session.content, &mut session.line_starts, &change)?; + transaction.undo.push(undo); + if !session.pending_full_replacement { + session + .pending_changes + .push(TextDocumentContentChangeEvent { + range: Some(Range { + start: Position { + line: change.start_line, + character: change.start_column, + }, + end: Position { + line: change.end_line, + character: change.end_column, + }, + }), + range_length: Some(change.range_length as u32), + text: change.text, + }); + } + } + + if let Some(previous_session) = session.model_session_id.replace(batch.model_session_id) + && session.model_session_id.as_deref() != Some(&previous_session) + { + session.retired_model_sessions.push_back(previous_session); + while session.retired_model_sessions.len() > MAX_RETIRED_MODEL_SESSIONS { + session.retired_model_sessions.pop_front(); + } + } + session.model_version_id = batch.model_version_id; + Ok(()) +} + +fn take_pending(session: &mut DocumentSession) -> Option { + let full_replacement = std::mem::take(&mut session.pending_full_replacement); + if (session.pending_changes.is_empty() && !full_replacement) + || session.sync_mode == SyncMode::None + { + session.pending_changes.clear(); + return None; + } + + session.lsp_version = session.lsp_version.saturating_add(1); + let full_replacement = full_replacement || session.sync_mode != SyncMode::Incremental; + let changes = if full_replacement { + session.pending_changes.clear(); + vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: session.content.clone(), + }] + } else { + std::mem::take(&mut session.pending_changes) + }; + Some(PendingDocumentChanges { + version: session.lsp_version, + changes, + full_replacement, + }) +} + +fn collect_line_starts(content: &str) -> Vec { + std::iter::once(0) + .chain( + content + .match_indices('\n') + .map(|(byte_offset, _)| byte_offset + 1), + ) + .collect() +} + +fn position_to_byte(content: &str, line_starts: &[usize], line: u32, column: u32) -> Option { + let line_start = *line_starts.get(line as usize)?; + let line_end = line_starts + .get(line as usize + 1) + .copied() + .unwrap_or(content.len()); + let mut utf16_column = 0; + for (relative_byte, character) in content[line_start..line_end].char_indices() { + if utf16_column == column { + return Some(line_start + relative_byte); + } + utf16_column += character.len_utf16() as u32; + if utf16_column > column || character == '\n' { + return None; + } + } + (utf16_column == column).then_some(line_end) +} + +fn apply_change( + content: &mut String, + line_starts: &mut Vec, + change: &DocumentChange, +) -> Result { + let start = position_to_byte(content, line_starts, change.start_line, change.start_column) + .context("LSP change starts at an invalid UTF-16 position")?; + let end = position_to_byte(content, line_starts, change.end_line, change.end_column) + .context("LSP change ends at an invalid UTF-16 position")?; + if end < start { + bail!("LSP change range ends before it starts"); + } + let removed = content[start..end].to_string(); + content.replace_range(start..end, &change.text); + + let first_removed_line = change.start_line as usize + 1; + let removed_line_end = change.end_line as usize + 1; + line_starts.drain(first_removed_line..removed_line_end); + let inserted_line_starts: Vec = change + .text + .match_indices('\n') + .map(|(offset, _)| start + offset + 1) + .collect(); + let inserted_line_count = inserted_line_starts.len(); + line_starts.splice(first_removed_line..first_removed_line, inserted_line_starts); + let byte_delta = change.text.len() as isize - removed.len() as isize; + for line_start in line_starts + .iter_mut() + .skip(first_removed_line + inserted_line_count) + { + *line_start = line_start.saturating_add_signed(byte_delta); + } + Ok(UndoStep::Edit { + start, + inserted_len: change.text.len(), + removed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn change( + offset: usize, + length: usize, + text: &str, + range: (u32, u32, u32, u32), + ) -> DocumentChange { + DocumentChange { + range_offset: offset, + range_length: length, + text: text.to_string(), + start_line: range.0, + start_column: range.1, + end_line: range.2, + end_column: range.3, + } + } + + fn batch(version: i64, changes: Vec) -> DocumentChangeBatch { + DocumentChangeBatch { + model_session_id: "model-a".to_string(), + model_version_id: version, + changes, + is_eol_change: false, + is_flush: false, + full_content: None, + } + } + + #[test] + fn applies_unicode_offsets_as_utf16_code_units() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "a😀b".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(2, vec![change(1, 2, "x", (0, 1, 0, 3))])) + .unwrap(); + + assert_eq!(sessions.content("/test.ts").as_deref(), Some("axb")); + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.version, 2); + assert_eq!(pending.changes[0].range.unwrap().end.character, 3); + } + + #[test] + fn applies_multiple_monaco_changes_from_highest_offset_first() { + let sessions = DocumentSessions::default(); + sessions.open( + "/test.ts", + "one\r\ntwo\r\nthree".to_string(), + SyncMode::Incremental, + ); + sessions + .queue( + "/test.ts", + batch( + 2, + vec![ + change(0, 3, "1", (0, 0, 0, 3)), + change(10, 5, "3", (2, 0, 2, 5)), + ], + ), + ) + .unwrap(); + + assert_eq!( + sessions.content("/test.ts").as_deref(), + Some("1\r\ntwo\r\n3") + ); + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.changes.len(), 2); + assert_eq!(pending.changes[0].range.unwrap().start.line, 2); + assert_eq!(pending.changes[1].range.unwrap().start.line, 0); + } + + #[test] + fn full_sync_servers_receive_one_final_replacement() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Full); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "d", (0, 3, 0, 3))])) + .unwrap(); + sessions + .queue("/test.ts", batch(3, vec![change(4, 0, "e", (0, 4, 0, 4))])) + .unwrap(); + + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.version, 2); + assert_eq!(pending.changes.len(), 1); + assert!(pending.changes[0].range.is_none()); + assert_eq!(pending.changes[0].text, "abcde"); + } + + #[test] + fn replacement_supersedes_queued_incremental_changes() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "d", (0, 3, 0, 3))])) + .unwrap(); + let mut replacement = batch(3, Vec::new()); + replacement.model_session_id = "buffer-store".to_string(); + replacement.is_flush = true; + replacement.full_content = Some("formatted\r\ntext".to_string()); + sessions.queue("/test.ts", replacement).unwrap(); + + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.changes.len(), 1); + assert!(pending.changes[0].range.is_none()); + assert_eq!(pending.changes[0].text, "formatted\r\ntext"); + assert!( + sessions + .queue("/test.ts", batch(4, vec![change(15, 0, "!", (1, 4, 1, 4))])) + .is_ok() + ); + } + + #[test] + fn rejects_stale_versions_within_a_model_session() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(3, vec![change(3, 0, "d", (0, 3, 0, 3))])) + .unwrap(); + assert!( + sessions + .queue("/test.ts", batch(2, vec![change(0, 0, "x", (0, 0, 0, 0))])) + .is_err() + ); + } + + #[test] + fn invalid_batch_does_not_partially_mutate_the_session() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + let result = sessions.queue( + "/test.ts", + batch( + 2, + vec![ + change(2, 1, "x", (0, 2, 0, 3)), + change(0, 1, "y", (9, 0, 9, 1)), + ], + ), + ); + + assert!(result.is_err()); + assert_eq!(sessions.content("/test.ts").as_deref(), Some("abc")); + assert!(sessions.flush("/test.ts").is_none()); + } + + #[test] + fn invalid_ipc_batch_does_not_commit_earlier_event_batches() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + let result = sessions.queue_many( + "/test.ts", + vec![ + batch(2, vec![change(3, 0, "d", (0, 3, 0, 3))]), + batch(3, vec![change(0, 1, "x", (4, 0, 4, 1))]), + ], + ); + + assert!(result.is_err()); + assert_eq!(sessions.content("/test.ts").as_deref(), Some("abc")); + assert!(sessions.flush("/test.ts").is_none()); + } + + #[test] + fn stale_debounce_epoch_cannot_flush_a_reopened_document() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "old".to_string(), SyncMode::Incremental); + let (old_epoch, old_generation, _) = sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "!", (0, 3, 0, 3))])) + .unwrap(); + sessions.close("/test.ts"); + sessions.open("/test.ts", "new".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "!", (0, 3, 0, 3))])) + .unwrap(); + + let mut emitted = false; + sessions + .emit_pending("/test.ts", Some((old_epoch, old_generation)), |_| { + emitted = true; + Ok(()) + }) + .unwrap(); + assert!(!emitted); + assert!(sessions.flush("/test.ts").is_some()); + } + + #[test] + fn failed_emission_restores_pending_changes_and_version() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "d", (0, 3, 0, 3))])) + .unwrap(); + + assert!( + sessions + .emit_pending("/test.ts", None, |_| bail!("send failed")) + .is_err() + ); + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.version, 2); + assert_eq!(pending.changes[0].text, "d"); + } + + fn replacement(content: &str) -> DocumentChangeBatch { + DocumentChangeBatch { + model_session_id: "buffer-store".to_string(), + model_version_id: 0, + changes: Vec::new(), + is_eol_change: false, + is_flush: true, + full_content: Some(content.to_string()), + } + } + + #[test] + fn invalid_batch_after_a_replacement_restores_the_original_document() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "one\ntwo".to_string(), SyncMode::Incremental); + sessions + .queue("/test.ts", batch(2, vec![change(0, 0, "a", (0, 0, 0, 0))])) + .unwrap(); + + let result = sessions.queue_many( + "/test.ts", + vec![ + batch(3, vec![change(4, 0, "b", (1, 0, 1, 0))]), + replacement("replaced"), + batch(4, vec![change(0, 0, "c", (0, 0, 0, 0))]), + batch(5, vec![change(0, 1, "x", (7, 0, 7, 1))]), + ], + ); + + assert!(result.is_err()); + assert_eq!(sessions.content("/test.ts").as_deref(), Some("aone\ntwo")); + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.changes.len(), 1); + assert_eq!(pending.changes[0].text, "a"); + assert!( + sessions + .queue("/test.ts", batch(3, vec![change(5, 0, "!", (1, 0, 1, 0))])) + .is_ok() + ); + assert_eq!(sessions.content("/test.ts").as_deref(), Some("aone\n!two")); + } + + #[test] + fn edits_after_a_replacement_are_sent_inside_the_full_document() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Incremental); + sessions.queue("/test.ts", replacement("xyz")).unwrap(); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "!", (0, 3, 0, 3))])) + .unwrap(); + + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.changes.len(), 1); + assert!(pending.changes[0].range.is_none()); + assert_eq!(pending.changes[0].text, "xyz!"); + assert!(sessions.flush("/test.ts").is_none()); + } + + #[test] + fn failed_full_emission_sends_the_latest_document_next_time() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", "abc".to_string(), SyncMode::Full); + sessions + .queue("/test.ts", batch(2, vec![change(3, 0, "d", (0, 3, 0, 3))])) + .unwrap(); + assert!( + sessions + .emit_pending("/test.ts", None, |pending| { + assert_eq!(pending.take_changes_for_send()[0].text, "abcd"); + bail!("send failed") + }) + .is_err() + ); + sessions + .queue("/test.ts", batch(3, vec![change(4, 0, "e", (0, 4, 0, 4))])) + .unwrap(); + + let pending = sessions.flush("/test.ts").unwrap(); + assert_eq!(pending.version, 2); + assert_eq!(pending.changes.len(), 1); + assert_eq!(pending.changes[0].text, "abcde"); + } + + #[test] + fn retired_model_sessions_are_bounded() { + let sessions = DocumentSessions::default(); + sessions.open("/test.ts", String::new(), SyncMode::Incremental); + for index in 0..(MAX_RETIRED_MODEL_SESSIONS + 4) { + let mut next = batch( + 1, + vec![change(index, 0, "x", (0, index as u32, 0, index as u32))], + ); + next.model_session_id = format!("model-{index}"); + sessions.queue("/test.ts", next).unwrap(); + } + + let inner = sessions.inner.lock().unwrap(); + let session = inner.get("/test.ts").unwrap(); + assert_eq!( + session.retired_model_sessions.len(), + MAX_RETIRED_MODEL_SESSIONS + ); + assert!( + !session + .retired_model_sessions + .contains(&"model-0".to_string()) + ); + } +} diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs index def2aec3f..e3ac87557 100644 --- a/crates/lsp/src/lib.rs +++ b/crates/lsp/src/lib.rs @@ -1,5 +1,6 @@ pub mod client; pub mod config; +mod document_sync; pub mod manager; mod manager_state; mod manager_support; @@ -7,5 +8,6 @@ mod runtime; pub mod types; pub mod utils; +pub use document_sync::{DocumentChange, DocumentChangeBatch}; pub use manager::LspManager; pub use types::{LspError, LspResult}; diff --git a/crates/lsp/src/manager.rs b/crates/lsp/src/manager.rs index 24cf9d700..638391c1c 100644 --- a/crates/lsp/src/manager.rs +++ b/crates/lsp/src/manager.rs @@ -1,6 +1,7 @@ use super::{ client::{LspClient, LspServerEnv}, config::{LspRegistry, LspSettings}, + document_sync::{DocumentChangeBatch, DocumentSessions, PendingDocumentChanges, SyncMode}, manager_state::{LspInstance, WorkspaceClients}, manager_support, runtime::AthasAppHandle as AppHandle, @@ -12,7 +13,7 @@ use serde_json::Value; use std::{ fs, path::{Path, PathBuf}, - time::Instant, + time::{Duration, Instant}, }; use tauri::Manager as TauriManager; @@ -22,6 +23,22 @@ pub struct LspManager { registry: LspRegistry, app_handle: AppHandle, settings: LspSettings, + document_sessions: DocumentSessions, +} + +fn send_document_changes( + client: &LspClient, + file_path: &str, + pending: &mut PendingDocumentChanges, +) -> Result<()> { + let uri = manager_support::text_document_identifier(file_path)?.uri; + client.text_document_did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + uri, + version: pending.version, + }, + content_changes: pending.take_changes_for_send(), + }) } impl LspManager { @@ -31,6 +48,7 @@ impl LspManager { registry: LspRegistry::new(), app_handle, settings: LspSettings::default(), + document_sessions: DocumentSessions::default(), } } @@ -350,11 +368,20 @@ impl LspManager { /// This will decrement the reference count and shutdown the server if it reaches 0 pub fn stop_lsp_for_file(&self, file_path: &PathBuf) -> Result<()> { log::info!("Stopping LSP for file: {:?}", file_path); + self.document_sessions.close(&file_path.to_string_lossy()); self.workspace_clients.stop_file(file_path); Ok(()) } pub fn get_client_for_file(&self, file_path: &str) -> Option { + let client = self.raw_client_for_file(file_path)?; + if let Err(error) = self.flush_document_changes_with_client(file_path, &client) { + log::warn!("Failed to flush document changes before LSP request: {error}"); + } + Some(client) + } + + fn raw_client_for_file(&self, file_path: &str) -> Option { self .workspace_clients .get_client_for_file(&PathBuf::from(file_path)) @@ -1374,78 +1401,134 @@ impl LspManager { let _extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); let client = self - .get_client_for_file(file_path) + .raw_client_for_file(file_path) .context("No LSP client for this file")?; + let sync_mode = match client.text_document_sync_kind() { + Some(kind) if kind == TextDocumentSyncKind::INCREMENTAL => SyncMode::Incremental, + Some(kind) if kind == TextDocumentSyncKind::FULL => SyncMode::Full, + _ => SyncMode::None, + }; + let params = DidOpenTextDocumentParams { text_document: TextDocumentItem { uri: manager_support::text_document_identifier(file_path)?.uri, language_id: language_id.unwrap_or_else(|| self.get_language_id_for_file(file_path)), version: 1, - text: content, + text: content.clone(), }, }; - client.text_document_did_open(params) + client.text_document_did_open(params)?; + self.document_sessions.open(file_path, content, sync_mode); + Ok(()) } + /// Compatibility path for explicit full-document replacements. Ordinary editor + /// input uses `queue_document_changes` and never sends the complete document. pub fn notify_document_change( &self, file_path: &str, content: String, - version: i32, + version: i64, ) -> Result<()> { - let path = PathBuf::from(file_path); - let _extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + self.queue_document_changes( + file_path, + DocumentChangeBatch { + model_session_id: "legacy-full-document".to_string(), + model_version_id: version, + changes: Vec::new(), + is_eol_change: false, + is_flush: true, + full_content: Some(content), + }, + )?; + self.flush_document_changes(file_path) + } + + pub fn queue_document_changes( + &self, + file_path: &str, + batch: DocumentChangeBatch, + ) -> Result { + self.queue_document_change_batches(file_path, vec![batch]) + } + pub fn queue_document_change_batches( + &self, + file_path: &str, + batches: Vec, + ) -> Result { + if batches.is_empty() { + bail!("LSP document change request is empty"); + } let client = self - .get_client_for_file(file_path) + .raw_client_for_file(file_path) .context("No LSP client for this file")?; + let (epoch, generation, projected_version) = + self.document_sessions.queue_many(file_path, batches)?; + let sessions = self.document_sessions.clone(); + let file_path = file_path.to_string(); + // Tauri runs sync commands on a thread without a Tokio context, so this must go through + // Tauri's runtime handle rather than `tokio::spawn`. + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(35)).await; + if let Err(error) = + sessions.emit_pending(&file_path, Some((epoch, generation)), |pending| { + send_document_changes(&client, &file_path, pending) + }) + { + log::warn!("Failed to send debounced LSP document changes: {error}"); + } + }); + Ok(projected_version) + } - let params = DidChangeTextDocumentParams { - text_document: VersionedTextDocumentIdentifier { - uri: manager_support::text_document_identifier(file_path)?.uri, - version, - }, - content_changes: vec![TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: content, - }], - }; - - client.text_document_did_change(params) + pub fn flush_document_changes(&self, file_path: &str) -> Result<()> { + let client = self + .raw_client_for_file(file_path) + .context("No LSP client for this file")?; + self.flush_document_changes_with_client(file_path, &client) } - pub fn notify_document_save(&self, file_path: &str, content: Option) -> Result<()> { - let path = PathBuf::from(file_path); - let _extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + fn flush_document_changes_with_client(&self, file_path: &str, client: &LspClient) -> Result<()> { + self + .document_sessions + .emit_pending(file_path, None, |pending| { + send_document_changes(client, file_path, pending) + }) + } + pub fn notify_document_save(&self, file_path: &str) -> Result<()> { let client = self - .get_client_for_file(file_path) + .raw_client_for_file(file_path) .context("No LSP client for this file")?; + self.flush_document_changes_with_client(file_path, &client)?; let params = DidSaveTextDocumentParams { text_document: manager_support::text_document_identifier(file_path)?, - text: content, + text: client + .should_include_text_on_save() + .then(|| self.document_sessions.content(file_path)) + .flatten(), }; client.text_document_did_save(params) } pub fn notify_document_close(&self, file_path: &str) -> Result<()> { - let path = PathBuf::from(file_path); - let _extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); - let client = self - .get_client_for_file(file_path) + .raw_client_for_file(file_path) .context("No LSP client for this file")?; + self.flush_document_changes_with_client(file_path, &client)?; let params = DidCloseTextDocumentParams { text_document: manager_support::text_document_identifier(file_path)?, }; - client.text_document_did_close(params) + let result = client.text_document_did_close(params); + self.document_sessions.close(file_path); + result } pub fn shutdown(&self) { diff --git a/crates/remote/src/terminal.rs b/crates/remote/src/terminal.rs index c5eebc853..5d4238125 100644 --- a/crates/remote/src/terminal.rs +++ b/crates/remote/src/terminal.rs @@ -96,14 +96,16 @@ pub(super) async fn create_remote_terminal( } pub(super) async fn write_remote_terminal(id: String, input: TerminalInput) -> Result<(), String> { - let terminals = REMOTE_TERMINALS - .lock() - .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; - let terminal = terminals - .get(&id) - .ok_or("Remote terminal connection not found")?; - let mut channel = terminal - .channel + let channel = { + let terminals = REMOTE_TERMINALS + .lock() + .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; + terminals + .get(&id) + .map(|terminal| terminal.channel.clone()) + .ok_or("Remote terminal connection not found")? + }; + let mut channel = channel .lock() .map_err(|e| format!("Failed to lock remote terminal channel: {}", e))?; channel @@ -117,14 +119,16 @@ pub(super) async fn write_remote_terminal(id: String, input: TerminalInput) -> R pub(super) async fn resize_remote_terminal(id: String, size: TerminalSize) -> Result<(), String> { let size = size.normalized(); - let terminals = REMOTE_TERMINALS - .lock() - .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; - let terminal = terminals - .get(&id) - .ok_or("Remote terminal connection not found")?; - let mut channel = terminal - .channel + let channel = { + let terminals = REMOTE_TERMINALS + .lock() + .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; + terminals + .get(&id) + .map(|terminal| terminal.channel.clone()) + .ok_or("Remote terminal connection not found")? + }; + let mut channel = channel .lock() .map_err(|e| format!("Failed to lock remote terminal channel: {}", e))?; channel @@ -139,21 +143,25 @@ pub(super) async fn resize_remote_terminal(id: String, size: TerminalSize) -> Re } pub(super) async fn set_remote_terminal_paused(id: String, paused: bool) -> Result<(), String> { - let terminals = REMOTE_TERMINALS - .lock() - .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; - let terminal = terminals - .get(&id) - .ok_or("Remote terminal connection not found")?; - terminal.reader_control.set_paused(paused); + let reader_control = { + let terminals = REMOTE_TERMINALS + .lock() + .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; + terminals + .get(&id) + .map(|terminal| terminal.reader_control.clone()) + .ok_or("Remote terminal connection not found")? + }; + reader_control.set_paused(paused); Ok(()) } pub(super) async fn close_remote_terminal(id: String) -> Result<(), String> { - let mut terminals = REMOTE_TERMINALS + let terminal = REMOTE_TERMINALS .lock() - .map_err(|e| format!("Failed to lock remote terminals: {}", e))?; - if let Some(terminal) = terminals.remove(&id) + .map_err(|e| format!("Failed to lock remote terminals: {}", e))? + .remove(&id); + if let Some(terminal) = terminal && let Ok(mut channel) = terminal.channel.lock() { terminal.reader_control.set_paused(false); diff --git a/src-tauri/src/commands/development/lsp/commands.rs b/src-tauri/src/commands/development/lsp/commands.rs index c0d05ca54..4c7d64bff 100644 --- a/src-tauri/src/commands/development/lsp/commands.rs +++ b/src-tauri/src/commands/development/lsp/commands.rs @@ -10,7 +10,7 @@ use super::{ }, }; use crate::app_runtime::AppHandle; -use athas_lsp::{LspError, LspManager, LspResult}; +use athas_lsp::{DocumentChangeBatch, LspError, LspManager, LspResult}; use athas_tooling::{LanguageToolConfigSet, ToolInstaller, ToolRegistry, ToolType}; use lsp_types::{ CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, CodeActionOrCommand, @@ -879,7 +879,7 @@ pub fn lsp_document_change( lsp_manager: State<'_, LspManager>, file_path: String, content: String, - version: i32, + version: i64, ) -> LspResult<()> { lsp_manager .notify_document_change(&file_path, content, version) @@ -887,13 +887,20 @@ pub fn lsp_document_change( } #[tauri::command] -pub fn lsp_document_save( +pub fn lsp_document_change_batch( lsp_manager: State<'_, LspManager>, file_path: String, - content: Option, -) -> LspResult<()> { + batches: Vec, +) -> LspResult { + lsp_manager + .queue_document_change_batches(&file_path, batches) + .map_err(Into::into) +} + +#[tauri::command] +pub fn lsp_document_save(lsp_manager: State<'_, LspManager>, file_path: String) -> LspResult<()> { lsp_manager - .notify_document_save(&file_path, content) + .notify_document_save(&file_path) .map_err(Into::into) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c9ab8e6dc..4e4d9de86 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -436,6 +436,7 @@ fn main() { lsp_get_java_class_file_contents, lsp_document_open, lsp_document_change, + lsp_document_change_batch, lsp_document_save, lsp_document_close, lsp_is_language_supported, diff --git a/src/features/editor/components/code-editor.tsx b/src/features/editor/components/code-editor.tsx index 6f8a4e788..4b918cdd4 100644 --- a/src/features/editor/components/code-editor.tsx +++ b/src/features/editor/components/code-editor.tsx @@ -44,7 +44,12 @@ import { rMarkdownChunkShouldPersistOutput, updateRMarkdownChunkOutput, } from "../notebook/rmarkdown-chunks"; -import type { EditorContentChangeOptions, Position, Range } from "../types/editor.types"; +import type { + EditorContentChangeOptions, + EditorDocumentChangeBatch, + Position, + Range, +} from "../types/editor.types"; import { ScrollDebugOverlay } from "./debug/scroll-debug-overlay"; import { HtmlPreview } from "./html/html-preview"; import { MonacoEditor } from "./monaco-editor"; @@ -163,7 +168,7 @@ const CodeEditor = ({ useCallback((state) => getBufferById(state.buffers, activeBufferId), [activeBufferId]), ); const editorViewKey = paneId && activeBufferId ? `${paneId}:${activeBufferId}` : activeBufferId; - const { handleContentChange } = useEditorAppStore.use.actions(); + const { handleContentChange, handleDocumentChange } = useEditorAppStore.use.actions(); const editorFontSize = useSettingsStore((state) => state.settings.fontSize); const editorLineHeight = useSettingsStore((state) => state.settings.editorLineHeight); const codeLensEnabled = useSettingsStore((state) => state.settings.codeLens); @@ -182,6 +187,14 @@ const CodeEditor = ({ const onChange = activeBuffer ? (onContentChange ?? (isActiveSurface ? handleContentChange : () => {})) : () => {}; + const onDocumentChange = + activeBuffer && !onContentChange + ? ( + batch: EditorDocumentChangeBatch, + previousCursorPosition?: Position, + previousSelection?: Range, + ) => handleDocumentChange(activeBuffer.id, batch, previousCursorPosition, previousSelection) + : undefined; const isPreviewBuffer = activeBuffer?.isPreview ?? false; const showMarkdownPreview = activeBuffer?.type === "markdownPreview" || @@ -614,7 +627,8 @@ const CodeEditor = ({ currentHighlightIndex={currentHighlightIndex} lineNumberStart={lineNumberStart} lineNumberMap={lineNumberMap} - onContentChange={onChange} + onContentChange={onContentChange ? onChange : undefined} + onDocumentChange={onDocumentChange} onScrollOffsetChange={syncLspOverlayTransform} onModelPositionResolverChange={handleModelPositionResolverChange} /> diff --git a/src/features/editor/components/monaco-editor.tsx b/src/features/editor/components/monaco-editor.tsx index 19abe6c3a..278a5a7c1 100644 --- a/src/features/editor/components/monaco-editor.tsx +++ b/src/features/editor/components/monaco-editor.tsx @@ -58,7 +58,13 @@ import EditorContextMenu from "../context-menu/context-menu"; import { toggleCaseText } from "../utils/text-operations"; import { useBufferStore } from "../stores/buffer.store"; import { useEditorStateStore } from "../stores/state.store"; -import type { EditorContentChangeOptions, Position, Range } from "../types/editor.types"; +import type { + EditorContentChangeOptions, + EditorDocumentChangeBatch, + EditorDocumentChangeResult, + Position, + Range, +} from "../types/editor.types"; import { getBufferById } from "../utils/buffer-index"; import { createEditorSelectionContextFromText } from "../utils/editor-agent-context"; import { fileOpenBenchmark } from "../utils/file-open-benchmark"; @@ -67,9 +73,11 @@ import { editorAPI } from "../extensions/api"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; import { - consumeLocalContentSnapshot, - rememberLocalContentSnapshot, + isExternalModelUpdate, + modelMatchesContent, + runWithExternalModelUpdate, } from "../engines/monaco/content-sync"; +import { deliverModelContentChange } from "../engines/monaco/document-change-batch"; import { clampMonacoHoverWidgets, mutationsContainMonacoHoverWidget, @@ -102,6 +110,7 @@ registerMonacoCodeLensProvider(); const EMPTY_DIAGNOSTICS: Diagnostic[] = []; const INACTIVE_CURSOR_POSITION: Position = { line: 0, column: 0, offset: 0 }; +let nextEditorSourceId = 1; /** How long the cursor rests on a line before its inline blame appears. */ const INLINE_GIT_BLAME_DELAY_MS = 450; /** How long the pointer rests on inline blame before its commit card opens. */ @@ -150,6 +159,11 @@ interface MonacoEditorProps { previousSelection?: Range, options?: EditorContentChangeOptions, ) => void; + onDocumentChange?: ( + batch: EditorDocumentChangeBatch, + previousCursorPosition?: Position, + previousSelection?: Range, + ) => EditorDocumentChangeResult; onScrollOffsetChange?: (scrollTop: number, scrollLeft: number) => void; onModelPositionResolverChange?: (resolver: EditorModelPositionResolver | null) => void; onMouseMove?: MouseEventHandler; @@ -173,6 +187,7 @@ export function MonacoEditor({ lineNumberStart, lineNumberMap, onContentChange, + onDocumentChange, onScrollOffsetChange, onModelPositionResolverChange, onMouseMove, @@ -189,9 +204,10 @@ export function MonacoEditor({ const modelRef = useRef(null); const vimAdapterRef = useRef(null); const vimStatusRef = useRef(null); - const applyingExternalChangeRef = useRef(false); - const previousContentRef = useRef(""); - const pendingLocalContentSnapshotsRef = useRef([]); + const sourceIdRef = useRef(`monaco-editor-${nextEditorSourceId++}`); + const modelSessionIdRef = useRef(""); + const appliedContentRevisionRef = useRef(0); + const bufferMatchesModelRef = useRef(false); const decorationsRef = useRef([]); const breakpointDecorationRef = useRef([]); const breakpointHoverDecorationRef = useRef([]); @@ -208,6 +224,7 @@ export function MonacoEditor({ const syncSelectionAgentActionRef = useRef<() => void>(() => {}); const isPointerSelectingRef = useRef(false); const latestContentChangeRef = useRef(onContentChange); + const latestDocumentChangeRef = useRef(onDocumentChange); const isActiveSurfaceRef = useRef(isActiveSurface); const activeBufferId = useBufferStore((state) => propBufferId ?? state.activeBufferId); const activeBuffer = useBufferStore( @@ -215,6 +232,7 @@ export function MonacoEditor({ ); const buffer = activeBuffer && activeBuffer.type === "editor" ? activeBuffer : null; const content = buffer?.content ?? ""; + const contentRevision = buffer?.contentRevision ?? 0; const filePath = buffer?.path ?? ""; const languageId = buffer?.languageOverride ?? getLanguageIdFromPath(filePath); const monacoLanguageId = toMonacoLanguageId(languageId); @@ -426,8 +444,9 @@ export function MonacoEditor({ useLayoutEffect(() => { latestContentChangeRef.current = onContentChange; + latestDocumentChangeRef.current = onDocumentChange; isActiveSurfaceRef.current = isActiveSurface; - }, [isActiveSurface, onContentChange]); + }, [isActiveSurface, onContentChange, onDocumentChange]); const selectionBufferId = buffer?.id; const selectionBufferPath = buffer?.path; @@ -780,6 +799,12 @@ export function MonacoEditor({ const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri); const model = acquiredModel.model; + // A model kept alive from an earlier view can hold text the buffer has since moved past. + if (!modelMatchesContent(model, content)) { + runWithExternalModelUpdate(model, () => model.setValue(content)); + } + bufferMatchesModelRef.current = modelMatchesContent(model, content); + modelSessionIdRef.current = acquiredModel.sessionId; const editor = monacoEditor.create(container, { model, automaticLayout: true, @@ -832,8 +857,7 @@ export function MonacoEditor({ editorRef.current = editor; modelRef.current = model; - previousContentRef.current = content; - pendingLocalContentSnapshotsRef.current = []; + appliedContentRevisionRef.current = contentRevision; if (filePath && fileOpenBenchmark.has(filePath)) { fileOpenBenchmark.mark(filePath, "monaco-created", `${model.getLineCount()} lines`); } @@ -1120,31 +1144,47 @@ export function MonacoEditor({ selectEntireModel(); }), editor.onDidChangeModelContent((event) => { - if (applyingExternalChangeRef.current) return; - const nextContent = model.getValue(); - const previousContent = previousContentRef.current; + if (isExternalModelUpdate(model)) return; const editorState = useEditorStateStore.getState(); - previousContentRef.current = nextContent; - rememberLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, nextContent); - latestContentChangeRef.current?.( - nextContent, - previousContent, - editorState.cursorPosition, - editorState.selection, - event.changes.length === 1 - ? { - contentChange: { - rangeOffset: event.changes[0].rangeOffset, - rangeLength: event.changes[0].rangeLength, - text: event.changes[0].text, - startLine: event.changes[0].range.startLineNumber - 1, - startColumn: event.changes[0].range.startColumn - 1, - endLine: event.changes[0].range.endLineNumber - 1, - endColumn: event.changes[0].range.endColumn - 1, - }, - } - : undefined, - ); + const handleDocumentChange = latestDocumentChangeRef.current; + if (handleDocumentChange) { + const { result, bufferMatchesModel } = deliverModelContentChange({ + event, + model, + sourceId: sourceIdRef.current, + modelSessionId: modelSessionIdRef.current, + bufferMatchesModel: bufferMatchesModelRef.current, + apply: (batch) => + handleDocumentChange(batch, editorState.cursorPosition, editorState.selection), + }); + bufferMatchesModelRef.current = bufferMatchesModel; + if (result.synchronized) { + appliedContentRevisionRef.current = Math.max( + appliedContentRevisionRef.current, + result.contentRevision, + ); + } + } else if (latestContentChangeRef.current) { + latestContentChangeRef.current( + model.getValue(), + undefined, + editorState.cursorPosition, + editorState.selection, + event.changes.length === 1 + ? { + contentChange: { + rangeOffset: event.changes[0].rangeOffset, + rangeLength: event.changes[0].rangeLength, + text: event.changes[0].text, + startLine: event.changes[0].range.startLineNumber - 1, + startColumn: event.changes[0].range.startColumn - 1, + endLine: event.changes[0].range.endLineNumber - 1, + endColumn: event.changes[0].range.endColumn - 1, + }, + } + : undefined, + ); + } syncCursorAndSelection(); syncSelectionAgentActionRef.current(); }), @@ -1541,25 +1581,22 @@ export function MonacoEditor({ const model = modelRef.current; if (!editor || !model) return; - const modelValue = model.getValue(); - if (modelValue === content) { - consumeLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, content); - previousContentRef.current = content; - return; - } - - // React can deliver older store echoes after Monaco has already accepted more typing. - if (consumeLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, content)) { - return; + // Revision 0 means the buffer was never changed through the store's revision-aware actions + // (virtual diff buffers are replaced wholesale), so only the text itself can tell. + if (contentRevision > 0 && contentRevision <= appliedContentRevisionRef.current) return; + appliedContentRevisionRef.current = Math.max( + appliedContentRevisionRef.current, + contentRevision, + ); + // Another view of the same model may already have applied this revision, and a store update + // that only echoes the model's text must not reset its undo stack or cursor. + if (!modelMatchesContent(model, content)) { + const selection = editor.getSelection(); + runWithExternalModelUpdate(model, () => model.setValue(content)); + if (selection) editor.setSelection(selection); } - - applyingExternalChangeRef.current = true; - const selection = editor.getSelection(); - model.setValue(content); - if (selection) editor.setSelection(selection); - previousContentRef.current = content; - applyingExternalChangeRef.current = false; - }, [content]); + bufferMatchesModelRef.current = modelMatchesContent(model, content); + }, [content, contentRevision]); useEffect(() => { if (!isActiveSurface || readOnly || isPreviewMode) return; diff --git a/src/features/editor/engines/monaco/content-sync.ts b/src/features/editor/engines/monaco/content-sync.ts index aa9fd335e..710d8652f 100644 --- a/src/features/editor/engines/monaco/content-sync.ts +++ b/src/features/editor/engines/monaco/content-sync.ts @@ -1,22 +1,30 @@ -const MAX_PENDING_LOCAL_SNAPSHOTS = 8; +const externalModelUpdateDepth = new WeakMap(); -export function rememberLocalContentSnapshot(snapshots: string[], content: string): void { - const existingIndex = snapshots.indexOf(content); - if (existingIndex >= 0) { - snapshots.splice(existingIndex, 1); +export function runWithExternalModelUpdate(model: object, update: () => T): T { + externalModelUpdateDepth.set(model, (externalModelUpdateDepth.get(model) ?? 0) + 1); + try { + return update(); + } finally { + const nextDepth = (externalModelUpdateDepth.get(model) ?? 1) - 1; + if (nextDepth === 0) externalModelUpdateDepth.delete(model); + else externalModelUpdateDepth.set(model, nextDepth); } +} - snapshots.push(content); - - while (snapshots.length > MAX_PENDING_LOCAL_SNAPSHOTS) { - snapshots.shift(); - } +export function isExternalModelUpdate(model: object): boolean { + return (externalModelUpdateDepth.get(model) ?? 0) > 0; } -export function consumeLocalContentSnapshot(snapshots: string[], content: string): boolean { - const index = snapshots.indexOf(content); - if (index === -1) return false; +interface ModelTextSource { + getValueLength: () => number; + getValue: () => string; +} - snapshots.splice(index, 1); - return true; +/** + * Whether the model holds exactly `content`. Monaco drops a leading BOM and rewrites lone CR and + * mixed line endings when it builds a model, so a buffer read from disk can differ from the model + * that shows it. Offsets in Monaco change events only line up with the buffer while they match. + */ +export function modelMatchesContent(model: ModelTextSource, content: string): boolean { + return model.getValueLength() === content.length && model.getValue() === content; } diff --git a/src/features/editor/engines/monaco/document-change-batch.ts b/src/features/editor/engines/monaco/document-change-batch.ts new file mode 100644 index 000000000..9ca64fd8d --- /dev/null +++ b/src/features/editor/engines/monaco/document-change-batch.ts @@ -0,0 +1,98 @@ +import type { + EditorDocumentChangeBatch, + EditorDocumentChangeResult, + EditorModelTextChange, +} from "../../types/editor.types"; + +interface ModelContentChange { + rangeOffset: number; + rangeLength: number; + text: string; + range: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + }; +} + +export interface ModelContentChangeEvent { + changes: readonly ModelContentChange[]; + versionId: number; + eol: string; + isEolChange: boolean; + isFlush: boolean; + isUndoing: boolean; + isRedoing: boolean; +} + +export interface ModelContentSource { + getValueLength: () => number; + getValue: () => string; +} + +interface DeliverModelContentChangeOptions { + event: ModelContentChangeEvent; + model: ModelContentSource; + sourceId: string; + modelSessionId: string; + /** Whether the buffer held exactly the model's text before this event. */ + bufferMatchesModel: boolean; + apply: (batch: EditorDocumentChangeBatch) => EditorDocumentChangeResult; +} + +function toModelTextChange(change: ModelContentChange): EditorModelTextChange { + return { + rangeOffset: change.rangeOffset, + rangeLength: change.rangeLength, + text: change.text, + startLine: change.range.startLineNumber - 1, + startColumn: change.range.startColumn - 1, + endLine: change.range.endLineNumber - 1, + endColumn: change.range.endColumn - 1, + }; +} + +function createBatch( + { event, model, sourceId, modelSessionId }: DeliverModelContentChangeOptions, + incremental: boolean, +): EditorDocumentChangeBatch { + const base = { + sourceId, + modelSessionId, + modelVersionId: event.versionId, + eol: event.eol === "\r\n" ? ("\r\n" as const) : ("\n" as const), + isEolChange: event.isEolChange, + isUndoing: event.isUndoing, + isRedoing: event.isRedoing, + }; + if (incremental) { + return { + ...base, + changes: event.changes.map(toModelTextChange), + isFlush: false, + expectedContentLength: model.getValueLength(), + }; + } + return { ...base, changes: [], isFlush: true, fullContent: model.getValue() }; +} + +/** + * Sends one Monaco content event to the buffer. The event's offsets describe the model's text, so + * they are only sent as a delta while the buffer holds that same text. Otherwise, and whenever a + * delta does not land cleanly, the buffer takes the model's full text instead. The model is the + * copy the user is typing into, so it is never rolled back to the buffer. + */ +export function deliverModelContentChange(options: DeliverModelContentChangeOptions): { + result: EditorDocumentChangeResult; + bufferMatchesModel: boolean; +} { + const { event, bufferMatchesModel, apply } = options; + const incremental = + bufferMatchesModel && !event.isFlush && !event.isEolChange && event.changes.length > 0; + let result = apply(createBatch(options, incremental)); + if (incremental && !result.synchronized) { + result = apply(createBatch(options, false)); + } + return { result, bufferMatchesModel: result.synchronized }; +} diff --git a/src/features/editor/engines/monaco/model-lifecycle.ts b/src/features/editor/engines/monaco/model-lifecycle.ts index 004d51a04..744b6c43c 100644 --- a/src/features/editor/engines/monaco/model-lifecycle.ts +++ b/src/features/editor/engines/monaco/model-lifecycle.ts @@ -3,15 +3,19 @@ import type * as Monaco from "monaco-editor"; interface SharedMonacoModel { model: Monaco.editor.ITextModel; + sessionId: string; referenceCount: number; releaseTimer: ReturnType | null; } const sharedModels = new Map(); const MODEL_RELEASE_GRACE_MS = 5_000; +let nextModelSessionId = 1; export interface AcquiredMonacoModel { model: Monaco.editor.ITextModel; + /** Identifies this model's version ids; a recreated model starts a new session. */ + sessionId: string; release: () => void; } @@ -24,7 +28,12 @@ export function acquireMonacoModel( let entry = sharedModels.get(key); if (!entry || entry.model.isDisposed()) { const model = monacoEditor.getModel(uri) ?? monacoEditor.createModel(content, languageId, uri); - entry = { model, referenceCount: 0, releaseTimer: null }; + entry = { + model, + sessionId: `monaco-model-${nextModelSessionId++}`, + referenceCount: 0, + releaseTimer: null, + }; sharedModels.set(key, entry); } @@ -38,6 +47,7 @@ export function acquireMonacoModel( let released = false; return { model, + sessionId: entry.sessionId, release: () => { if (released) return; released = true; diff --git a/src/features/editor/history/history-patches.ts b/src/features/editor/history/history-patches.ts new file mode 100644 index 000000000..6511d50de --- /dev/null +++ b/src/features/editor/history/history-patches.ts @@ -0,0 +1,101 @@ +import type { EditorModelTextChange } from "../types/editor.types"; +import type { HistoryPatchBatch, HistoryPatchChange } from "../types/history.types"; +import { applyEditorTextChanges } from "../utils/editor-text-changes"; + +function applyPatchChanges( + content: string, + changes: readonly HistoryPatchChange[], + direction: "forward" | "reverse", +): string | null { + for (const change of changes) { + const expected = direction === "forward" ? change.beforeText : change.afterText; + if (content.slice(change.rangeOffset, change.rangeOffset + expected.length) !== expected) { + return null; + } + } + const replacements = changes.map((change) => ({ + rangeOffset: change.rangeOffset, + rangeLength: direction === "forward" ? change.beforeText.length : change.afterText.length, + text: direction === "forward" ? change.afterText : change.beforeText, + startLine: 0, + startColumn: 0, + endLine: 0, + endColumn: 0, + })); + return applyEditorTextChanges(content, replacements); +} + +export function createHistoryPatchBatch( + previousContent: string, + changes: readonly EditorModelTextChange[], +): HistoryPatchBatch | null { + const orderedChanges = [...changes].sort((left, right) => left.rangeOffset - right.rangeOffset); + let previousEnd = 0; + let lengthDelta = 0; + for (const change of orderedChanges) { + const end = change.rangeOffset + change.rangeLength; + if ( + !Number.isInteger(change.rangeOffset) || + !Number.isInteger(change.rangeLength) || + change.rangeOffset < previousEnd || + change.rangeOffset < 0 || + change.rangeLength < 0 || + end > previousContent.length + ) { + return null; + } + previousEnd = end; + lengthDelta += change.text.length - change.rangeLength; + } + const patches = orderedChanges.map((change) => { + return { + rangeOffset: change.rangeOffset, + beforeText: previousContent.slice( + change.rangeOffset, + change.rangeOffset + change.rangeLength, + ), + afterText: change.text, + }; + }); + + return { + beforeLength: previousContent.length, + afterLength: previousContent.length + lengthDelta, + changes: patches, + }; +} + +export function applyHistoryPatchBatch( + content: string, + batch: HistoryPatchBatch, + direction: "forward" | "reverse", +): string | null { + if (direction === "forward") { + if (content.length !== batch.beforeLength) return null; + return applyPatchChanges(content, batch.changes, "forward"); + } + + if (content.length !== batch.afterLength) return null; + let accumulatedDelta = 0; + const reverseChanges = batch.changes.map((change) => { + const rangeOffset = change.rangeOffset + accumulatedDelta; + accumulatedDelta += change.afterText.length - change.beforeText.length; + return { ...change, rangeOffset }; + }); + return applyPatchChanges(content, reverseChanges, "reverse"); +} + +export function applyHistoryPatchBatches( + content: string, + batches: readonly HistoryPatchBatch[], + direction: "forward" | "reverse", +): string | null { + const ordered = direction === "forward" ? batches : [...batches].reverse(); + let result = content; + for (const batch of ordered) { + const next = applyHistoryPatchBatch(result, batch, direction); + if (next === null) return null; + result = next; + } + return result; +} diff --git a/src/features/editor/history/undo-group-tracker.ts b/src/features/editor/history/undo-group-tracker.ts index c34e9cbdf..8a49ad729 100644 --- a/src/features/editor/history/undo-group-tracker.ts +++ b/src/features/editor/history/undo-group-tracker.ts @@ -5,8 +5,14 @@ import { type UndoEditDelta, type UndoEditOperation, } from "./undo-grouping"; -import type { HistoryEntry } from "../types/history.types"; -import type { EditorTextChange, Position, Range } from "../types/editor.types"; +import type { HistoryEntry, PatchHistoryEntry, StoredHistoryEntry } from "../types/history.types"; +import type { + EditorModelTextChange, + EditorTextChange, + Position, + Range, +} from "../types/editor.types"; +import { createHistoryPatchBatch } from "./history-patches"; interface PendingUndoGroup { baseEntry: HistoryEntry; @@ -15,6 +21,12 @@ interface PendingUndoGroup { lastEditDelta: UndoEditDelta; } +interface PendingPatchUndoGroup { + entry: PatchHistoryEntry; + operation: UndoEditOperation; + lastEditDelta: UndoEditDelta; +} + export interface UndoTrackOptions { previousCursorPosition?: Position; previousSelection?: Range; @@ -37,14 +49,17 @@ function cloneRange(range?: Range): Range | undefined { export class EditorUndoGroupTracker { private readonly lastBufferContent = new Map(); private readonly pendingUndoGroups = new Map(); + private readonly pendingPatchUndoGroups = new Map(); cleanup(bufferId: string): void { this.pendingUndoGroups.delete(bufferId); + this.pendingPatchUndoGroups.delete(bufferId); this.lastBufferContent.delete(bufferId); } sync(bufferId: string, content: string): void { this.pendingUndoGroups.delete(bufferId); + this.pendingPatchUndoGroups.delete(bufferId); this.lastBufferContent.set(bufferId, content); } @@ -53,13 +68,18 @@ export class EditorUndoGroupTracker { previousContent: string, nextContent: string, options: UndoTrackOptions = {}, - ): HistoryEntry[] { + ): StoredHistoryEntry[] { if (previousContent === nextContent) { this.lastBufferContent.set(bufferId, nextContent); return []; } - const entries: HistoryEntry[] = []; + const entries: StoredHistoryEntry[] = []; + const pendingPatchGroup = this.pendingPatchUndoGroups.get(bufferId); + if (pendingPatchGroup) { + entries.push(pendingPatchGroup.entry); + this.pendingPatchUndoGroups.delete(bufferId); + } const pendingGroup = this.pendingUndoGroups.get(bufferId); const previousOperation = pendingGroup?.operation ?? "other"; const delta = options.contentChange @@ -102,11 +122,84 @@ export class EditorUndoGroupTracker { return entries; } + trackChanges( + bufferId: string, + previousContent: string, + nextContent: string, + changes: readonly EditorModelTextChange[], + options: Omit = {}, + ): StoredHistoryEntry[] { + if (changes.length === 0 || previousContent === nextContent) return []; + const patch = createHistoryPatchBatch(previousContent, changes); + if (!patch || patch.afterLength !== nextContent.length) { + return this.track(bufferId, previousContent, nextContent, options); + } + + const entries: StoredHistoryEntry[] = []; + const pendingSnapshotGroup = this.pendingUndoGroups.get(bufferId); + if (pendingSnapshotGroup) { + const snapshotEntry = this.entryForGroup(pendingSnapshotGroup); + if (snapshotEntry) entries.push(snapshotEntry); + this.pendingUndoGroups.delete(bufferId); + } + this.lastBufferContent.delete(bufferId); + const pendingGroup = this.pendingPatchUndoGroups.get(bufferId); + const previousOperation = pendingGroup?.operation ?? "other"; + const delta = + changes.length === 1 + ? getUndoEditDeltaFromChange(previousContent, changes[0], previousOperation) + : { + operation: "other" as const, + startOffset: Math.min(...changes.map((change) => change.rangeOffset)), + endOffset: Math.max( + ...changes.map((change) => change.rangeOffset + change.text.length), + ), + insertedText: "", + removedText: "", + insertedLength: changes.reduce((total, change) => total + change.text.length, 0), + removedLength: changes.reduce((total, change) => total + change.rangeLength, 0), + }; + const operation = delta.operation; + const entry: PatchHistoryEntry = { + kind: "patch", + patches: [patch], + beforeLength: patch.beforeLength, + afterLength: patch.afterLength, + cursorPosition: clonePosition(options.previousCursorPosition), + selection: cloneRange(options.previousSelection), + timestamp: Date.now(), + }; + + if ( + pendingGroup && + shouldStartNewUndoGroupForDelta(pendingGroup.operation, pendingGroup.lastEditDelta, delta) + ) { + entries.push(pendingGroup.entry); + this.pendingPatchUndoGroups.set(bufferId, { entry, operation, lastEditDelta: delta }); + } else if (pendingGroup) { + pendingGroup.entry.patches.push(patch); + pendingGroup.entry.afterLength = patch.afterLength; + pendingGroup.operation = operation; + pendingGroup.lastEditDelta = delta; + } else { + this.pendingPatchUndoGroups.set(bufferId, { entry, operation, lastEditDelta: delta }); + } + + return entries; + } + getTrackedContent(bufferId: string): string | undefined { return this.lastBufferContent.get(bufferId); } - flush(bufferId: string, currentContent: string): HistoryEntry | null { + flush(bufferId: string, currentContent: string): StoredHistoryEntry | null { + const pendingPatchGroup = this.pendingPatchUndoGroups.get(bufferId); + this.pendingPatchUndoGroups.delete(bufferId); + if (pendingPatchGroup) { + return pendingPatchGroup.entry.afterLength === currentContent.length + ? pendingPatchGroup.entry + : null; + } const pendingGroup = this.pendingUndoGroups.get(bufferId); this.pendingUndoGroups.delete(bufferId); this.lastBufferContent.set(bufferId, currentContent); diff --git a/src/features/editor/hooks/use-lsp-integration.ts b/src/features/editor/hooks/use-lsp-integration.ts index 25fa4fd26..4045e8f66 100644 --- a/src/features/editor/hooks/use-lsp-integration.ts +++ b/src/features/editor/hooks/use-lsp-integration.ts @@ -14,8 +14,6 @@ interface UseLspIntegrationOptions { value: string; } -const DOCUMENT_CHANGE_DEBOUNCE_MS = 75; - export const useLspIntegration = ({ enabled = true, filePath, @@ -29,10 +27,7 @@ export const useLspIntegration = ({ () => Boolean(activeFilePath && extensionRegistry.isLspSupported(activeFilePath)), [activeFilePath, installedExtensions], ); - const documentChangeTimerRef = useRef(undefined); - const documentVersionsRef = useRef>(new Map()); const latestValueRef = useRef(value); - const openedDocumentsRef = useRef>(new Set()); useEffect(() => { latestValueRef.current = value; @@ -53,7 +48,7 @@ export const useLspIntegration = ({ ); if (isStillOpen) return; - if (openedDocumentsRef.current.has(filePath)) { + if (lspClient.isDocumentOpen(filePath)) { lspClient.notifyDocumentClose(filePath).catch((error) => { console.error("LSP document close error:", error); }); @@ -61,24 +56,21 @@ export const useLspIntegration = ({ console.error("LSP stop for file error:", error); }); } - - documentVersionsRef.current.delete(filePath); - openedDocumentsRef.current.delete(filePath); }; - if (openedDocumentsRef.current.has(filePath)) { + if (lspClient.isDocumentOpen(filePath)) { return cleanupDocument; } + let cancelled = false; + const initializeLsp = async () => { try { logger.debug("LspIntegration", `Starting LSP for ${filePath} in ${workspacePath}`); - documentVersionsRef.current.set(filePath, 1); const started = await lspClient.startForFile(filePath, workspacePath); - if (!started) return; + if (!started || cancelled) return; await lspClient.notifyDocumentOpen(filePath, latestValueRef.current); - openedDocumentsRef.current.add(filePath); logger.debug("LspIntegration", `LSP started and document opened for ${filePath}`); } catch (error) { console.error("LSP initialization error:", error); @@ -90,36 +82,9 @@ export const useLspIntegration = ({ }); return () => { + cancelled = true; cancelInitialization(); cleanupDocument(); }; }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath]); - - useEffect(() => { - if (!enabled || !filePath || !isLspSupported) return; - if (!openedDocumentsRef.current.has(filePath)) return; - - if (documentChangeTimerRef.current) { - clearTimeout(documentChangeTimerRef.current); - } - - documentChangeTimerRef.current = setTimeout(() => { - if (!openedDocumentsRef.current.has(filePath)) return; - - const currentVersion = documentVersionsRef.current.get(filePath) || 1; - const newVersion = currentVersion + 1; - documentVersionsRef.current.set(filePath, newVersion); - - lspClient.notifyDocumentChange(filePath, value, newVersion).catch((error) => { - console.error("LSP document change error:", error); - }); - }, DOCUMENT_CHANGE_DEBOUNCE_MS); - - return () => { - if (documentChangeTimerRef.current) { - clearTimeout(documentChangeTimerRef.current); - documentChangeTimerRef.current = undefined; - } - }; - }, [enabled, filePath, isLspSupported, lspClient, value]); }; diff --git a/src/features/editor/hooks/use-tokenizer.ts b/src/features/editor/hooks/use-tokenizer.ts index d99f5bf79..5c7927ce2 100644 --- a/src/features/editor/hooks/use-tokenizer.ts +++ b/src/features/editor/hooks/use-tokenizer.ts @@ -7,7 +7,10 @@ import { useCallback, useRef, useState } from "react"; import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { logger } from "@/features/editor/utils/logger"; import { getLanguageAssetConfig } from "../lib/wasm-parser/extension-assets"; -import { tokenizerWorkerClient } from "../lib/wasm-parser/tokenizer-worker-client"; +import { + TokenizerRequestSupersededError, + tokenizerWorkerClient, +} from "../lib/wasm-parser/tokenizer-worker-client"; import type { HighlightToken } from "../types/wasm-parser/wasm-parser.types"; import { buildLineOffsetMap, normalizeLineEndings, type Token } from "../utils/html"; import { getLanguageIdFromPath } from "../utils/language-id"; @@ -319,6 +322,7 @@ export function useTokenizer({ try { const result = await tokenizerWorkerClient.tokenize({ bufferId, + latestKey: `editor-tokenizer:${bufferId}`, content: normalizedText, languageId, wasmPath: languageAssets.wasmPath, @@ -336,7 +340,12 @@ export function useTokenizer({ previousContent: result.normalizedText, }; } catch (error) { - if (requestVersion !== requestVersionRef.current) return; + if ( + requestVersion !== requestVersionRef.current || + error instanceof TokenizerRequestSupersededError + ) { + return; + } logger.warn("Editor", "[Tokenizer] Full tokenization failed:", error); if (hasLineBasedSyntaxFallback(languageId)) { const fallbackTokens = tokenizeLineBasedSyntax(normalizedText, languageId); @@ -414,6 +423,7 @@ export function useTokenizer({ const result = await tokenizerWorkerClient.tokenize({ bufferId, + latestKey: `editor-tokenizer:${bufferId}`, content: normalizedText, languageId, wasmPath: languageAssets.wasmPath, @@ -466,7 +476,12 @@ export function useTokenizer({ }, BACKGROUND_FULL_TOKENIZE_DELAY_MS); } } catch (error) { - if (requestVersion !== requestVersionRef.current) return; + if ( + requestVersion !== requestVersionRef.current || + error instanceof TokenizerRequestSupersededError + ) { + return; + } logger.warn("Editor", "[Tokenizer] Range tokenization failed:", error); if (hasLineBasedSyntaxFallback(languageId)) { const fallbackTokens = tokenizeLineBasedSyntax(normalizedText, languageId, { diff --git a/src/features/editor/lib/wasm-parser/tokenizer-range.ts b/src/features/editor/lib/wasm-parser/tokenizer-range.ts new file mode 100644 index 000000000..e44a91db8 --- /dev/null +++ b/src/features/editor/lib/wasm-parser/tokenizer-range.ts @@ -0,0 +1,24 @@ +import type { HighlightToken } from "../../types/wasm-parser/wasm-parser.types"; + +export interface TokenizerRangeBounds { + startPosition: { row: number; column: number }; + endPosition: { row: number; column: number }; + startIndex: number; + endIndex: number; +} + +interface IndexedRange { + startIndex: number; + endIndex: number; +} + +export function intersectsTokenizerRange(value: IndexedRange, range: TokenizerRangeBounds) { + return value.endIndex > range.startIndex && value.startIndex < range.endIndex; +} + +export function filterTokensToRange( + tokens: HighlightToken[], + range?: TokenizerRangeBounds, +): HighlightToken[] { + return range ? tokens.filter((token) => intersectsTokenizerRange(token, range)) : tokens; +} diff --git a/src/features/editor/lib/wasm-parser/tokenizer-worker-client.ts b/src/features/editor/lib/wasm-parser/tokenizer-worker-client.ts index 212c32dcd..ba80d451b 100644 --- a/src/features/editor/lib/wasm-parser/tokenizer-worker-client.ts +++ b/src/features/editor/lib/wasm-parser/tokenizer-worker-client.ts @@ -10,10 +10,43 @@ interface PendingRequest { reject: (reason?: unknown) => void; } -class TokenizerWorkerClient { +interface TokenizeParams { + bufferId: string; + content: string; + languageId: string; + wasmPath?: string; + highlightQuery?: string; + highlightQueryUrl?: string; + mode: "full" | "range"; + viewportRange?: ViewportRangePayload; + latestKey?: string; +} + +interface LatestRequest { + params: TokenizeParams; + generation: number; + resolve: (value: TokenizerWorkerResult) => void; + reject: (reason?: unknown) => void; +} + +interface LatestRequestState { + generation: number; + running: boolean; + queued?: LatestRequest; +} + +export class TokenizerRequestSupersededError extends Error { + constructor() { + super("Tokenizer request was superseded"); + this.name = "TokenizerRequestSupersededError"; + } +} + +export class TokenizerWorkerClient { private worker: Worker | null = null; private requestId = 0; private pending = new Map(); + private latestRequests = new Map(); private ensureWorker(): Worker { if (this.worker) return this.worker; @@ -60,20 +93,19 @@ class TokenizerWorkerClient { await this.post({ id, type: "reset", bufferId }); } - async tokenize(params: { - bufferId: string; - content: string; - languageId: string; - wasmPath?: string; - highlightQueryUrl?: string; - mode: "full" | "range"; - viewportRange?: ViewportRangePayload; - }): Promise { + private async tokenizeImmediately(params: TokenizeParams): Promise { const id = ++this.requestId; const response = await this.post>({ id, type: "tokenize", - ...params, + bufferId: params.bufferId, + content: params.content, + languageId: params.languageId, + wasmPath: params.wasmPath, + highlightQuery: params.highlightQuery, + highlightQueryUrl: params.highlightQueryUrl, + mode: params.mode, + viewportRange: params.viewportRange, }); return { @@ -81,6 +113,53 @@ class TokenizerWorkerClient { normalizedText: response.normalizedText ?? params.content, }; } + + private runLatestRequest(key: string, request: LatestRequest) { + const state = this.latestRequests.get(key); + if (!state) return; + state.running = true; + + void this.tokenizeImmediately(request.params) + .then((result) => { + if (request.generation === state.generation) { + request.resolve(result); + } else { + request.reject(new TokenizerRequestSupersededError()); + } + }) + .catch(request.reject) + .finally(() => { + const queued = state.queued; + state.queued = undefined; + if (queued) { + this.runLatestRequest(key, queued); + } else { + this.latestRequests.delete(key); + } + }); + } + + tokenize(params: TokenizeParams): Promise { + const key = params.latestKey; + if (!key) return this.tokenizeImmediately(params); + + return new Promise((resolve, reject) => { + const state = this.latestRequests.get(key) ?? { generation: 0, running: false }; + const generation = state.generation + 1; + state.generation = generation; + const request = { params, generation, resolve, reject }; + + if (state.running) { + state.queued?.reject(new TokenizerRequestSupersededError()); + state.queued = request; + this.latestRequests.set(key, state); + return; + } + + this.latestRequests.set(key, state); + this.runLatestRequest(key, request); + }); + } } export const tokenizerWorkerClient = new TokenizerWorkerClient(); diff --git a/src/features/editor/lib/wasm-parser/tokenizer-worker.ts b/src/features/editor/lib/wasm-parser/tokenizer-worker.ts index 57ecc4230..d8fd5d5d1 100644 --- a/src/features/editor/lib/wasm-parser/tokenizer-worker.ts +++ b/src/features/editor/lib/wasm-parser/tokenizer-worker.ts @@ -22,6 +22,11 @@ import type { TokenizerWorkerResponse, ViewportRangePayload, } from "./worker-protocol"; +import { + filterTokensToRange, + intersectsTokenizerRange, + type TokenizerRangeBounds, +} from "./tokenizer-range"; interface WorkerSession { bufferId: string; @@ -58,9 +63,9 @@ function buildLineStartOffsets(content: string): number[] { async function getLoadedParser( languageId: string, - assets?: { wasmPath?: string; highlightQueryUrl?: string }, + assets?: { wasmPath?: string; highlightQuery?: string; highlightQueryUrl?: string }, ): Promise { - if (wasmParserLoader.isLoaded(languageId)) { + if (wasmParserLoader.isLoaded(languageId) && !assets?.highlightQuery) { return wasmParserLoader.getParser(languageId); } @@ -68,6 +73,7 @@ async function getLoadedParser( const config: ParserConfig = { languageId, wasmPath: assets?.wasmPath || defaultAssets.wasmPath, + highlightQuery: assets?.highlightQuery, highlightQueryUrl: assets?.highlightQueryUrl || defaultAssets.highlightQueryUrl, }; @@ -121,8 +127,11 @@ function toHighlightTokens(captures: QueryCapture[]): HighlightToken[] { return dedupeHighlightTokens(tokens); } -function getRangeQueryOptions(content: string, viewportRange?: ViewportRangePayload) { - if (!viewportRange) return {}; +function getRangeQueryOptions( + content: string, + viewportRange?: ViewportRangePayload, +): TokenizerRangeBounds | undefined { + if (!viewportRange) return undefined; const normalized = normalizeLineEndings(content); const lineOffsets = buildLineStartOffsets(normalized); @@ -191,6 +200,7 @@ async function handleTokenize( const normalizedContent = normalizeLineEndings(message.content); const loadedParser = await getLoadedParser(message.languageId, { wasmPath: message.wasmPath, + highlightQuery: message.highlightQuery, highlightQueryUrl: message.highlightQueryUrl, }); const existing = sessions.get(message.bufferId); @@ -228,20 +238,17 @@ async function handleTokenize( } const query = loadedParser.highlightQuery; - const tokens = query - ? toHighlightTokens( - query.captures( - tree.rootNode, - message.mode === "range" - ? getRangeQueryOptions(normalizedContent, message.viewportRange) - : {}, - ), - ) - : []; + const range = + message.mode === "range" + ? getRangeQueryOptions(normalizedContent, message.viewportRange) + : undefined; + const tokens = query ? toHighlightTokens(query.captures(tree.rootNode, range ?? {})) : []; const injectionRules = getInjectionRules(message.languageId); if (injectionRules) { - const injectionNodes = findInjectionNodes(tree.rootNode, injectionRules); + const injectionNodes = findInjectionNodes(tree.rootNode, injectionRules).filter(({ node }) => + range ? intersectsTokenizerRange(node, range) : true, + ); const embeddedTokenGroups = await Promise.all( injectionNodes.map(async ({ rule, node, parentNode }) => { @@ -293,7 +300,9 @@ async function handleTokenize( } } - tokens.push(...getLanguageOverlayTokens(message.languageId, normalizedContent)); + tokens.push( + ...filterTokensToRange(getLanguageOverlayTokens(message.languageId, normalizedContent), range), + ); const nextSession = upsertTree( existing, diff --git a/src/features/editor/lib/wasm-parser/worker-protocol.ts b/src/features/editor/lib/wasm-parser/worker-protocol.ts index 8e5a24e22..3e266171d 100644 --- a/src/features/editor/lib/wasm-parser/worker-protocol.ts +++ b/src/features/editor/lib/wasm-parser/worker-protocol.ts @@ -14,6 +14,7 @@ export type TokenizerWorkerRequest = content: string; languageId: string; wasmPath?: string; + highlightQuery?: string; highlightQueryUrl?: string; mode: "full" | "range"; viewportRange?: ViewportRangePayload; diff --git a/src/features/editor/lsp/lsp-client.ts b/src/features/editor/lsp/lsp-client.ts index d61a945d0..4cb17b4f6 100644 --- a/src/features/editor/lsp/lsp-client.ts +++ b/src/features/editor/lsp/lsp-client.ts @@ -24,7 +24,9 @@ import type { } from "@/features/diagnostics/types/diagnostics.types"; import type { BackendLanguageToolConfigSet } from "@/extensions/runtime/language-tool-config"; import { hasTextContent } from "@/features/panes/types/pane-content.types"; +import { subscribeToEditorDocumentChanges } from "../services/editor-document-events"; import { useBufferStore } from "../stores/buffer.store"; +import type { EditorDocumentChangeEvent } from "../types/editor.types"; import { getSourceEditorBufferByPath } from "../utils/buffer-index"; import { logger } from "../utils/logger"; import type { LspSemanticTokensResponse } from "./semantic-token-types"; @@ -153,6 +155,13 @@ function withoutWorkspaceEdit(actionPayload: unknown): unknown { return commandPayload; } +const MAX_DOCUMENT_CHANGE_RETRIES = 2; + +type DocumentChangeBatchPayload = Pick< + EditorDocumentChangeEvent, + "modelSessionId" | "modelVersionId" | "changes" | "isEolChange" | "isFlush" | "fullContent" +>; + export class LspClient { private static instance: LspClient | null = null; private activeLanguageServers = new Set(); // workspace:language format @@ -162,11 +171,150 @@ export class LspClient { private repairLanguageServerPromises = new Map>(); private openDocuments = new Set(); private documentVersions = new Map(); + private openingDocuments = new Map>(); + private backendOpenedDocuments = new Set(); + private closingDocuments = new Set(); + private documentLifecycleGenerations = new Map(); + private documentChangeQueues = new Map(); + private documentChangeTimers = new Map>(); + private documentChangeSendChains = new Map>(); + private documentChangeRetries = new Map(); + private documentsNeedingResync = new Set(); private constructor() { this.setupDiagnosticsListener(); this.setupCrashListener(); this.setupWorkspaceEditListener(); + subscribeToEditorDocumentChanges((event) => this.queueDocumentChange(event)); + } + + private queueDocumentChange(event: EditorDocumentChangeEvent): void { + if ( + this.closingDocuments.has(event.filePath) || + (!this.openDocuments.has(event.filePath) && !this.openingDocuments.has(event.filePath)) + ) { + return; + } + const queue = this.documentChangeQueues.get(event.filePath) ?? []; + queue.push(event); + this.documentChangeQueues.set(event.filePath, queue); + + const existingTimer = this.documentChangeTimers.get(event.filePath); + if (existingTimer) clearTimeout(existingTimer); + this.documentChangeTimers.set( + event.filePath, + setTimeout(() => void this.flushDocumentChanges(event.filePath), 40), + ); + } + + /** The editor's current text for a document, which every published change is already part of. */ + private getCurrentDocumentContent(filePath: string): string | null { + const buffer = getSourceEditorBufferByPath(useBufferStore.getState().buffers, filePath); + return buffer && hasTextContent(buffer) ? buffer.content : null; + } + + private flushDocumentChanges(filePath: string): Promise { + const timer = this.documentChangeTimers.get(filePath); + if (timer) clearTimeout(timer); + this.documentChangeTimers.delete(filePath); + + const queued = this.documentChangeQueues.get(filePath) ?? []; + const needsResync = this.documentsNeedingResync.has(filePath); + if (queued.length === 0 && !needsResync) { + return this.documentChangeSendChains.get(filePath) ?? Promise.resolve(); + } + this.documentChangeQueues.delete(filePath); + + // After deltas were lost the server's copy can no longer be patched, so it is replaced with the + // editor's text as of now. The text is read here, not when the request goes out, so edits + // queued in between are sent on top of it exactly once. + const resyncContent = needsResync ? this.getCurrentDocumentContent(filePath) : null; + const batches: DocumentChangeBatchPayload[] = + resyncContent !== null + ? [ + { + modelSessionId: "lsp-resync", + modelVersionId: 0, + changes: [], + isEolChange: false, + isFlush: true, + fullContent: resyncContent, + }, + ] + : queued.map( + ({ modelSessionId, modelVersionId, changes, isEolChange, isFlush, fullContent }) => ({ + modelSessionId, + modelVersionId, + changes, + isEolChange, + isFlush, + fullContent, + }), + ); + if (batches.length === 0) { + return this.documentChangeSendChains.get(filePath) ?? Promise.resolve(); + } + + const previous = this.documentChangeSendChains.get(filePath) ?? Promise.resolve(); + const send = previous + .catch(() => undefined) + .then(async () => { + const opening = this.openingDocuments.get(filePath); + if (opening) await opening; + if (!this.openDocuments.has(filePath)) return; + const version = await invoke("lsp_document_change_batch", { filePath, batches }); + this.documentVersions.set(filePath, version); + this.documentChangeRetries.delete(filePath); + if (resyncContent !== null) this.documentsNeedingResync.delete(filePath); + }) + .catch((error) => { + logger.error("LSPClient", "LSP document change error:", error); + if (!this.openDocuments.has(filePath)) return; + if (resyncContent !== null) { + // Stays flagged: the next edit or request tries the full replacement again. + return; + } + const retries = this.documentChangeRetries.get(filePath) ?? 0; + if (retries < MAX_DOCUMENT_CHANGE_RETRIES) { + this.documentChangeRetries.set(filePath, retries + 1); + const laterChanges = this.documentChangeQueues.get(filePath) ?? []; + this.documentChangeQueues.set(filePath, [...queued, ...laterChanges]); + } else { + this.documentChangeRetries.delete(filePath); + this.documentsNeedingResync.add(filePath); + this.documentChangeQueues.delete(filePath); + } + this.documentChangeTimers.set( + filePath, + setTimeout(() => void this.flushDocumentChanges(filePath), 80 * (retries + 1)), + ); + }); + this.documentChangeSendChains.set(filePath, send); + return send; + } + + private async invokeForDocument( + command: string, + args: { filePath: string; [key: string]: unknown }, + ): Promise { + await this.flushDocumentChangesBeforeOperation(args.filePath); + return invoke(command, args); + } + + /** + * Sends pending edits before a request that reads the document. A failed send schedules its own + * retry or full resync, so the request still goes ahead instead of failing until restart. + */ + private async flushDocumentChangesBeforeOperation(filePath: string): Promise { + for (let attempt = 0; attempt <= MAX_DOCUMENT_CHANGE_RETRIES + 1; attempt += 1) { + await this.flushDocumentChanges(filePath); + if ( + !this.documentChangeQueues.has(filePath) && + (!this.documentsNeedingResync.has(filePath) || attempt > MAX_DOCUMENT_CHANGE_RETRIES) + ) { + return; + } + } } /** @@ -892,7 +1040,7 @@ export class LspClient { "LSPClient", `Active language servers: ${Array.from(this.activeLanguageServers).join(", ")}`, ); - const completions = await invoke("lsp_get_completions", { + const completions = await this.invokeForDocument("lsp_get_completions", { filePath, line, character, @@ -913,7 +1061,10 @@ export class LspClient { async resolveCompletionItem(filePath: string, item: CompletionItem): Promise { try { - return await invoke("lsp_resolve_completion_item", { filePath, item }); + return await this.invokeForDocument("lsp_resolve_completion_item", { + filePath, + item, + }); } catch (error) { if (!isCanceledLspRequest(error)) { logger.debug("LSPClient", "LSP completion resolve unavailable:", error); @@ -924,7 +1075,7 @@ export class LspClient { async getHover(filePath: string, line: number, character: number): Promise { try { - return await invoke("lsp_get_hover", { + return await this.invokeForDocument("lsp_get_hover", { filePath, line, character, @@ -946,7 +1097,7 @@ export class LspClient { ): Promise { try { logger.debug("LSPClient", `Getting ${label} for ${filePath}:${line}:${character}`); - const locations = await invoke(command, { + const locations = await this.invokeForDocument(command, { filePath, line, character, @@ -1005,7 +1156,9 @@ export class LspClient { async getSemanticTokens(filePath: string): Promise { try { - return await invoke("lsp_get_semantic_tokens", { filePath }); + return await this.invokeForDocument("lsp_get_semantic_tokens", { + filePath, + }); } catch (error) { if (isCanceledLspRequest(error)) return null; logger.warn("LSPClient", `LSP semantic tokens unavailable: ${stringifyLspError(error)}`); @@ -1022,7 +1175,7 @@ export class LspClient { }[] > { try { - return await invoke("lsp_get_code_lens", { filePath }); + return await this.invokeForDocument("lsp_get_code_lens", { filePath }); } catch (error) { if (isCanceledLspRequest(error)) return []; logger.error("LSPClient", "LSP code lens error:", error); @@ -1032,7 +1185,7 @@ export class LspClient { async getFoldingRanges(filePath: string): Promise { try { - return await invoke("lsp_get_folding_ranges", { filePath }); + return await this.invokeForDocument("lsp_get_folding_ranges", { filePath }); } catch (error) { if (isCanceledLspRequest(error)) return []; logger.warn("LSPClient", `LSP folding ranges unavailable: ${stringifyLspError(error)}`); @@ -1042,7 +1195,7 @@ export class LspClient { async getSelectionRanges(filePath: string, positions: Position[]): Promise { try { - return await invoke("lsp_get_selection_ranges", { + return await this.invokeForDocument("lsp_get_selection_ranges", { filePath, positions, }); @@ -1059,7 +1212,7 @@ export class LspClient { character: number, ): Promise { try { - return await invoke("lsp_get_document_highlights", { + return await this.invokeForDocument("lsp_get_document_highlights", { filePath, line, character, @@ -1077,7 +1230,7 @@ export class LspClient { character: number, ): Promise { try { - return await invoke("lsp_prepare_call_hierarchy", { + return await this.invokeForDocument("lsp_prepare_call_hierarchy", { filePath, line, character, @@ -1093,7 +1246,7 @@ export class LspClient { item: CallHierarchyItem, ): Promise { try { - return await invoke("lsp_get_incoming_calls", { + return await this.invokeForDocument("lsp_get_incoming_calls", { filePath, item, }); @@ -1108,7 +1261,7 @@ export class LspClient { item: CallHierarchyItem, ): Promise { try { - return await invoke("lsp_get_outgoing_calls", { + return await this.invokeForDocument("lsp_get_outgoing_calls", { filePath, item, }); @@ -1124,7 +1277,7 @@ export class LspClient { character: number, ): Promise { try { - return await invoke("lsp_prepare_type_hierarchy", { + return await this.invokeForDocument("lsp_prepare_type_hierarchy", { filePath, line, character, @@ -1137,7 +1290,10 @@ export class LspClient { async getSupertypes(filePath: string, item: TypeHierarchyItem): Promise { try { - return await invoke("lsp_get_supertypes", { filePath, item }); + return await this.invokeForDocument("lsp_get_supertypes", { + filePath, + item, + }); } catch (error) { logger.debug("LSPClient", "LSP supertypes unavailable:", error); return []; @@ -1146,7 +1302,10 @@ export class LspClient { async getSubtypes(filePath: string, item: TypeHierarchyItem): Promise { try { - return await invoke("lsp_get_subtypes", { filePath, item }); + return await this.invokeForDocument("lsp_get_subtypes", { + filePath, + item, + }); } catch (error) { logger.debug("LSPClient", "LSP subtypes unavailable:", error); return []; @@ -1155,9 +1314,12 @@ export class LspClient { async getOnTypeFormattingTriggerCharacters(filePath: string): Promise { try { - return await invoke("lsp_get_on_type_formatting_trigger_characters", { - filePath, - }); + return await this.invokeForDocument( + "lsp_get_on_type_formatting_trigger_characters", + { + filePath, + }, + ); } catch (error) { logger.debug("LSPClient", "LSP on-type formatting triggers unavailable:", error); return []; @@ -1173,7 +1335,7 @@ export class LspClient { insertSpaces: boolean, ): Promise { try { - return await invoke("lsp_format_on_type", { + return await this.invokeForDocument("lsp_format_on_type", { filePath, line, character, @@ -1203,7 +1365,7 @@ export class LspClient { }[] > { try { - return await invoke("lsp_get_inlay_hints", { + return await this.invokeForDocument("lsp_get_inlay_hints", { filePath, startLine, endLine, @@ -1230,7 +1392,7 @@ export class LspClient { > { try { logger.debug("LSPClient", `Getting document symbols for ${filePath}`); - const symbols = await invoke< + const symbols = await this.invokeForDocument< { name: string; kind: string; @@ -1309,7 +1471,7 @@ export class LspClient { activeParameter?: number; } | null> { try { - return await invoke("lsp_get_signature_help", { + return await this.invokeForDocument("lsp_get_signature_help", { filePath, line, character, @@ -1322,7 +1484,9 @@ export class LspClient { async getSignatureTriggerCharacters(filePath: string): Promise { try { - return await invoke("lsp_get_signature_trigger_characters", { filePath }); + return await this.invokeForDocument("lsp_get_signature_trigger_characters", { + filePath, + }); } catch (error) { logger.debug("LSPClient", "LSP signature trigger characters unavailable:", error); return []; @@ -1331,7 +1495,9 @@ export class LspClient { async formatDocument(filePath: string, content: string): Promise { try { - const edits = await invoke("lsp_format_document", { filePath }); + const edits = await this.invokeForDocument("lsp_format_document", { + filePath, + }); if (!edits.length) return content; return applyTextEditsToContent(content, edits); } catch (error) { @@ -1349,7 +1515,7 @@ export class LspClient { }, ): Promise { try { - const edits = await invoke("lsp_format_range", { + const edits = await this.invokeForDocument("lsp_format_range", { filePath, startLine: range.start.line, startCharacter: range.start.character, @@ -1380,7 +1546,7 @@ export class LspClient { > { try { logger.debug("LSPClient", `Getting references for ${filePath}:${line}:${character}`); - const references = await invoke< + const references = await this.invokeForDocument< | { uri: string; range: { @@ -1412,7 +1578,7 @@ export class LspClient { ): Promise { try { logger.debug("LSPClient", `Renaming at ${filePath}:${line}:${character} to "${newName}"`); - const result = await invoke("lsp_rename", { + const result = await this.invokeForDocument("lsp_rename", { filePath, line, character, @@ -1434,7 +1600,7 @@ export class LspClient { character: number, ): Promise { try { - return await invoke("lsp_prepare_rename", { + return await this.invokeForDocument("lsp_prepare_rename", { filePath, line, character, @@ -1468,7 +1634,7 @@ export class LspClient { } : rangeOrDiagnostic; const contextDiagnostics = diagnostics ?? (isDiagnostic ? [rangeOrDiagnostic] : []); - return await invoke("lsp_get_code_actions", { + return await this.invokeForDocument("lsp_get_code_actions", { filePath, context: { ...range, @@ -1508,10 +1674,13 @@ export class LspClient { actionPayload = withoutWorkspaceEdit(actionPayload); } - const result = await invoke("lsp_apply_code_action", { - filePath, - actionPayload, - }); + const result = await this.invokeForDocument( + "lsp_apply_code_action", + { + filePath, + actionPayload, + }, + ); return appliedEdit && !result.applied ? { applied: true, reason: result.reason } : result; } catch (error) { @@ -1523,18 +1692,37 @@ export class LspClient { } } - async notifyDocumentOpen(filePath: string, content: string): Promise { - try { + async notifyDocumentOpen(filePath: string, fallbackContent: string): Promise { + if (this.openDocuments.has(filePath)) return; + const opening = this.openingDocuments.get(filePath); + if (opening) return opening; + + // Edits are queued from this point on, so the opened text must include every edit published + // before it. The store has them; a React prop may still be a render behind. + const content = this.getCurrentDocumentContent(filePath) ?? fallbackContent; + + this.closingDocuments.delete(filePath); + const generation = (this.documentLifecycleGenerations.get(filePath) ?? 0) + 1; + this.documentLifecycleGenerations.set(filePath, generation); + const open = (async () => { logger.debug("LSPClient", `Opening document: ${filePath}`); const { extensionRegistry } = await import("@/extensions/registry/extension-registry"); const languageId = extensionRegistry.getLanguageId(filePath) || undefined; await invoke("lsp_document_open", { filePath, content, languageId }); + this.backendOpenedDocuments.add(filePath); + if (this.documentLifecycleGenerations.get(filePath) !== generation) return; this.openDocuments.add(filePath); this.documentVersions.set(filePath, 1); useLspStore.getState().actions.markDocumentStateChanged(); - } catch (error) { - logger.error("LSPClient", "LSP document open error:", error); - } + })() + .catch((error) => { + this.documentChangeQueues.delete(filePath); + logger.error("LSPClient", "LSP document open error:", error); + }) + .finally(() => this.openingDocuments.delete(filePath)); + this.openingDocuments.set(filePath, open); + await open; + await this.flushDocumentChanges(filePath); } async executeCommand( @@ -1542,7 +1730,7 @@ export class LspClient { command: string, argumentsPayload: unknown[] = [], ): Promise { - return await invoke("lsp_execute_command", { + return await this.invokeForDocument("lsp_execute_command", { filePath, command, arguments: argumentsPayload, @@ -1550,43 +1738,64 @@ export class LspClient { } async getJavaClassFileContents(filePath: string, uri: string): Promise { - return await invoke("lsp_get_java_class_file_contents", { filePath, uri }); - } - - async notifyDocumentChange(filePath: string, content: string, version: number): Promise { - try { - this.openDocuments.add(filePath); - this.documentVersions.set(filePath, version); - await invoke("lsp_document_change", { - filePath, - content, - version, - }); - } catch (error) { - logger.error("LSPClient", "LSP document change error:", error); - } + return await this.invokeForDocument("lsp_get_java_class_file_contents", { + filePath, + uri, + }); } - async notifyDocumentSave(filePath: string, content: string): Promise { + /** The saved text, when the server wants it, comes from the backend's synchronized copy. */ + async notifyDocumentSave(filePath: string): Promise { try { - await invoke("lsp_document_save", { filePath, content }); + await this.flushDocumentChangesBeforeOperation(filePath); + await invoke("lsp_document_save", { filePath }); } catch (error) { logger.debug("LSPClient", "LSP document save notification skipped:", error); } } async notifyDocumentClose(filePath: string): Promise { + this.closingDocuments.add(filePath); + this.documentLifecycleGenerations.set( + filePath, + (this.documentLifecycleGenerations.get(filePath) ?? 0) + 1, + ); + const opening = this.openingDocuments.get(filePath); + if (opening) { + const timer = this.documentChangeTimers.get(filePath); + if (timer) clearTimeout(timer); + this.documentChangeTimers.delete(filePath); + this.documentChangeQueues.delete(filePath); + await opening; + } else { + try { + await this.flushDocumentChangesBeforeOperation(filePath); + } catch (error) { + logger.warn("LSPClient", "Closing document with unsent changes:", error); + } + } const wasOpen = this.openDocuments.delete(filePath); this.documentVersions.delete(filePath); + const timer = this.documentChangeTimers.get(filePath); + if (timer) clearTimeout(timer); + this.documentChangeTimers.delete(filePath); + this.documentChangeQueues.delete(filePath); + this.documentChangeSendChains.delete(filePath); + this.documentChangeRetries.delete(filePath); + this.documentsNeedingResync.delete(filePath); useDiagnosticsStore.getState().actions.clearDiagnosticsForOwner(filePath, "lsp"); if (wasOpen) { useLspStore.getState().actions.markDocumentStateChanged(); } try { - await invoke("lsp_document_close", { filePath }); + if (this.backendOpenedDocuments.delete(filePath)) { + await invoke("lsp_document_close", { filePath }); + } } catch (error) { logger.error("LSPClient", "LSP document close error:", error); + } finally { + this.closingDocuments.delete(filePath); } } diff --git a/src/features/editor/markdown/code-highlight.ts b/src/features/editor/markdown/code-highlight.ts index 52e7835d8..99ac87037 100644 --- a/src/features/editor/markdown/code-highlight.ts +++ b/src/features/editor/markdown/code-highlight.ts @@ -4,7 +4,10 @@ import { fetchHighlightQuery, getLanguageAssetConfig, } from "@/features/editor/lib/wasm-parser/extension-assets"; -import { tokenizeCode } from "@/features/editor/lib/wasm-parser/tokenizer"; +import { + TokenizerRequestSupersededError, + tokenizerWorkerClient, +} from "@/features/editor/lib/wasm-parser/tokenizer-worker-client"; import type { HighlightToken } from "@/features/editor/types/wasm-parser/wasm-parser.types"; import { normalizeCodeFenceLanguage } from "./language-map"; @@ -15,6 +18,8 @@ export interface CodeHighlightSegment { } const TOKEN_CACHE = new Map(); +const TOKEN_REQUESTS = new Map>(); +const TOKEN_CACHE_LIMIT = 200; const TREE_SITTER_LANGUAGE_ALIASES: Record = { csharp: "csharp", @@ -162,6 +167,7 @@ function resolveHighlightLanguage(language: string): string | null { async function tokenizeForLanguage( code: string, languageId: string, + requestKey: string, ): Promise { try { const cached = await indexedDBParserCache.get(languageId); @@ -188,56 +194,111 @@ async function tokenizeForLanguage( } catch {} } - const tokens = await tokenizeCode(code, languageId, { + const result = await tokenizerWorkerClient.tokenize({ + bufferId: requestKey, + latestKey: requestKey, + content: code, languageId, wasmPath, highlightQuery, highlightQueryUrl, + mode: "full", }); return normalizeSegments( - tokens.map((token: HighlightToken) => ({ + result.tokens.map((token: HighlightToken) => ({ start: token.startIndex, end: token.endIndex, className: token.type, })), code.length, ); - } catch { + } catch (error) { + if (error instanceof TokenizerRequestSupersededError) throw error; return null; } } +function hashCodeContent(code: string) { + let first = 2_166_136_261; + let second = 2_166_136_261; + for (let index = 0; index < code.length; index++) { + const value = code.charCodeAt(index); + first = Math.imul(first ^ value, 16_777_619); + second = Math.imul(second ^ (value + index), 16_777_619); + } + return `${code.length}:${(first >>> 0).toString(16)}:${(second >>> 0).toString(16)}`; +} + +function cacheSegments(key: string, segments: CodeHighlightSegment[]) { + TOKEN_CACHE.delete(key); + TOKEN_CACHE.set(key, segments); + if (TOKEN_CACHE.size > TOKEN_CACHE_LIMIT) { + const oldestKey = TOKEN_CACHE.keys().next().value; + if (oldestKey) TOKEN_CACHE.delete(oldestKey); + } +} + export async function getCodeHighlightSegments( code: string, language: string, + requestKey?: string, ): Promise { const languageId = resolveHighlightLanguage(language); if (!languageId) return []; - const cacheKey = `${languageId}:${code}`; + const contentKey = hashCodeContent(code); + const cacheKey = `${languageId}:${contentKey}`; const cached = TOKEN_CACHE.get(cacheKey); - if (cached) return cached; - - const treeSitterSegments = await tokenizeForLanguage(code, languageId); - const segments = - treeSitterSegments && treeSitterSegments.length > 0 - ? treeSitterSegments - : fallbackSegmentsForLanguage(code, languageId); + if (cached) { + TOKEN_CACHE.delete(cacheKey); + TOKEN_CACHE.set(cacheKey, cached); + return cached; + } - TOKEN_CACHE.set(cacheKey, segments); - return segments; + const pending = TOKEN_REQUESTS.get(cacheKey); + if (pending) return pending; + + const tokenRequest = (async () => { + let treeSitterSegments: CodeHighlightSegment[] | null; + try { + treeSitterSegments = await tokenizeForLanguage( + code, + languageId, + requestKey ?? `markdown-code:${languageId}:${contentKey}`, + ); + } catch { + // A newer request for the same view replaced this one; its result must not be cached as + // the answer for this code. + return fallbackSegmentsForLanguage(code, languageId); + } + const segments = + treeSitterSegments && treeSitterSegments.length > 0 + ? treeSitterSegments + : fallbackSegmentsForLanguage(code, languageId); + + cacheSegments(cacheKey, segments); + return segments; + })().finally(() => { + TOKEN_REQUESTS.delete(cacheKey); + }); + TOKEN_REQUESTS.set(cacheKey, tokenRequest); + return tokenRequest; } export function renderHighlightedCodeHtml(code: string, segments: CodeHighlightSegment[]): string { return applySegmentsToHtml(code, segments); } -export async function highlightMarkdownCodeBlocks(html: string): Promise { +export async function highlightMarkdownCodeBlocks( + html: string, + requestKey?: string, +): Promise { const codeBlockRegex = /
([\s\S]*?)<\/code><\/pre>/g;
   const parts: string[] = [];
   let cursor = 0;
   let deadline = 0;
+  let index = 0;
 
   for (const match of html.matchAll(codeBlockRegex)) {
     if (performance.now() >= deadline) {
@@ -246,7 +307,12 @@ export async function highlightMarkdownCodeBlocks(html: string): Promise
     }
 
     const rawCode = match[2].replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
-    const segments = await getCodeHighlightSegments(rawCode, match[1]);
+    const segments = await getCodeHighlightSegments(
+      rawCode,
+      match[1],
+      requestKey ? `${requestKey}:${index}` : undefined,
+    );
+    index += 1;
     const replacement = segments.length
       ? `
${renderHighlightedCodeHtml(rawCode, segments)}
` : match[0]; diff --git a/src/features/editor/markdown/use-highlighted-markdown.ts b/src/features/editor/markdown/use-highlighted-markdown.ts index d68ebd7d1..42b496a0a 100644 --- a/src/features/editor/markdown/use-highlighted-markdown.ts +++ b/src/features/editor/markdown/use-highlighted-markdown.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { highlightMarkdownCodeBlocks } from "./code-highlight"; import { parseMarkdown, type ParseMarkdownOptions } from "./parser"; @@ -7,6 +7,7 @@ export function useHighlightedMarkdown( options?: ParseMarkdownOptions, ) { const frontMatter = options?.frontMatter; + const requestKey = `markdown-preview:${useId()}`; const parsedHtml = useMemo(() => { if (!content) return ""; return parseMarkdown(content, { frontMatter }); @@ -21,7 +22,7 @@ export function useHighlightedMarkdown( return undefined; } - void highlightMarkdownCodeBlocks(parsedHtml).then((highlightedHtml) => { + void highlightMarkdownCodeBlocks(parsedHtml, requestKey).then((highlightedHtml) => { if (!cancelled) { setHtml(highlightedHtml); } @@ -30,7 +31,7 @@ export function useHighlightedMarkdown( return () => { cancelled = true; }; - }, [parsedHtml]); + }, [parsedHtml, requestKey]); return html; } diff --git a/src/features/editor/notebook/highlighted-code.tsx b/src/features/editor/notebook/highlighted-code.tsx index 69614353b..096c7ce7b 100644 --- a/src/features/editor/notebook/highlighted-code.tsx +++ b/src/features/editor/notebook/highlighted-code.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { getCodeHighlightSegments, renderHighlightedCodeHtml, @@ -11,13 +11,14 @@ interface HighlightedCodeProps { } export function HighlightedCode({ code, language, className }: HighlightedCodeProps) { + const requestKey = `notebook-highlight:${useId()}`; const [html, setHtml] = useState(() => renderHighlightedCodeHtml(code, [])); useEffect(() => { let cancelled = false; setHtml(renderHighlightedCodeHtml(code, [])); - void getCodeHighlightSegments(code, language).then((segments) => { + void getCodeHighlightSegments(code, language, requestKey).then((segments) => { if (!cancelled) { setHtml(renderHighlightedCodeHtml(code, segments)); } @@ -26,7 +27,7 @@ export function HighlightedCode({ code, language, className }: HighlightedCodePr return () => { cancelled = true; }; - }, [code, language]); + }, [code, language, requestKey]); return (
diff --git a/src/features/editor/services/editor-document-events.ts b/src/features/editor/services/editor-document-events.ts
new file mode 100644
index 000000000..f6b662cdf
--- /dev/null
+++ b/src/features/editor/services/editor-document-events.ts
@@ -0,0 +1,22 @@
+import type { EditorDocumentChangeEvent } from "../types/editor.types";
+
+type EditorDocumentChangeListener = (event: EditorDocumentChangeEvent) => void;
+
+const listeners = new Set();
+
+export function publishEditorDocumentChange(event: EditorDocumentChangeEvent): void {
+  for (const listener of listeners) {
+    try {
+      listener(event);
+    } catch (error) {
+      console.error("Editor document change listener failed:", error);
+    }
+  }
+}
+
+export function subscribeToEditorDocumentChanges(
+  listener: EditorDocumentChangeListener,
+): () => void {
+  listeners.add(listener);
+  return () => listeners.delete(listener);
+}
diff --git a/src/features/editor/stores/buffer-content-factory.ts b/src/features/editor/stores/buffer-content-factory.ts
index 06c847663..4c221c1d9 100644
--- a/src/features/editor/stores/buffer-content-factory.ts
+++ b/src/features/editor/stores/buffer-content-factory.ts
@@ -19,6 +19,7 @@ export const createPaneContent = (id: string, spec: OpenContentSpec): PaneConten
         path: spec.path,
         name: spec.name,
         content: spec.content,
+        contentRevision: 0,
         savedContent: spec.content,
         isDirty: false,
         isVirtual: spec.isVirtual ?? false,
diff --git a/src/features/editor/stores/buffer-history-tracking.ts b/src/features/editor/stores/buffer-history-tracking.ts
index ab7d281f8..dcc8e0dbc 100644
--- a/src/features/editor/stores/buffer-history-tracking.ts
+++ b/src/features/editor/stores/buffer-history-tracking.ts
@@ -1,5 +1,10 @@
 import { EditorUndoGroupTracker } from "@/features/editor/history/undo-group-tracker";
-import type { EditorTextChange, Position, Range } from "@/features/editor/types/editor.types";
+import type {
+  EditorModelTextChange,
+  EditorTextChange,
+  Position,
+  Range,
+} from "@/features/editor/types/editor.types";
 import { useHistoryStore } from "@/features/editor/stores/history.store";
 
 const undoGroupTracker = new EditorUndoGroupTracker();
@@ -59,6 +64,7 @@ export function trackBufferHistoryChange({
   previousSelection,
   skipUndoGrouping,
   contentChange,
+  contentChanges,
 }: {
   bufferId: string;
   currentContent: string;
@@ -68,6 +74,7 @@ export function trackBufferHistoryChange({
   previousSelection?: Range;
   skipUndoGrouping?: boolean;
   contentChange?: EditorTextChange;
+  contentChanges?: readonly EditorModelTextChange[];
 }): void {
   if (skipUndoGrouping) {
     trackImmediateBufferHistoryChange({
@@ -81,17 +88,24 @@ export function trackBufferHistoryChange({
   }
 
   const lastTrackedContent = undoGroupTracker.getTrackedContent(bufferId);
-  const contentBeforeChange = lastTrackedContent ?? previousContent ?? currentContent;
+  const contentBeforeChange = contentChanges?.length
+    ? (previousContent ?? currentContent)
+    : (lastTrackedContent ?? previousContent ?? currentContent);
 
-  if (lastTrackedContent === undefined) {
+  if (!contentChanges?.length && lastTrackedContent === undefined) {
     undoGroupTracker.sync(bufferId, contentBeforeChange);
   }
 
-  const historyEntries = undoGroupTracker.track(bufferId, contentBeforeChange, nextContent, {
-    previousCursorPosition,
-    previousSelection,
-    contentChange,
-  });
+  const historyEntries = contentChanges?.length
+    ? undoGroupTracker.trackChanges(bufferId, contentBeforeChange, nextContent, contentChanges, {
+        previousCursorPosition,
+        previousSelection,
+      })
+    : undoGroupTracker.track(bufferId, contentBeforeChange, nextContent, {
+        previousCursorPosition,
+        previousSelection,
+        contentChange,
+      });
   const { pushHistory } = useHistoryStore.getState().actions;
   for (const entry of historyEntries) {
     pushHistory(bufferId, entry);
diff --git a/src/features/editor/stores/buffer.store.ts b/src/features/editor/stores/buffer.store.ts
index 05b001150..5535b06e6 100644
--- a/src/features/editor/stores/buffer.store.ts
+++ b/src/features/editor/stores/buffer.store.ts
@@ -50,6 +50,12 @@ import type {
   TerminalContent,
   TokenEntry,
 } from "@/features/panes/types/pane-content.types";
+import type {
+  EditorDocumentChangeBatch,
+  EditorDocumentChangeResult,
+} from "@/features/editor/types/editor.types";
+import { publishEditorDocumentChange } from "@/features/editor/services/editor-document-events";
+import { applyEditorTextChanges } from "@/features/editor/utils/editor-text-changes";
 import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store";
 import {
   isEditorContent,
@@ -62,6 +68,11 @@ import { createSelectors } from "@/utils/zustand-selectors";
 /** @deprecated Use `PaneContent` directly. Kept for backward compatibility. */
 export type Buffer = PaneContent;
 
+const lastAppliedModelVersionByBuffer = new Map<
+  string,
+  { modelSessionId: string; modelVersionId: number }
+>();
+
 interface PendingClose {
   bufferId: string;
   type: "single" | "others" | "all" | "to-left" | "to-right";
@@ -166,6 +177,11 @@ interface BufferActions {
     markDirty?: boolean,
     diffData?: GitDiff | MultiFileDiff,
   ) => void;
+  applyBufferContentChanges: (
+    bufferId: string,
+    batch: EditorDocumentChangeBatch,
+    markDirty?: boolean,
+  ) => EditorDocumentChangeResult;
   updateBufferTokens: (bufferId: string, tokens: TokenEntry[]) => void;
   updateBufferLanguage: (bufferId: string, language: string) => void;
   markBufferDirty: (bufferId: string, isDirty: boolean) => void;
@@ -1248,11 +1264,15 @@ const createBufferStore = (workspaceId: string) => {
           if (buffer.content === content && !diffData) return;
 
           let promotedPreviewBufferId: string | null = null;
+          const contentRevision = buffer.type === "editor" ? (buffer.contentRevision ?? 0) + 1 : 0;
           set((state) => {
             const buf = state.buffers.find((b) => b.id === bufferId);
             if (!buf || !isEditableContent(buf)) return;
 
             buf.content = content;
+            if (buf.type === "editor") {
+              buf.contentRevision = contentRevision;
+            }
             if (diffData && buf.type === "diff") {
               buf.diffData = diffData;
             }
@@ -1275,6 +1295,96 @@ const createBufferStore = (workspaceId: string) => {
           if (promotedPreviewBufferId) {
             paneStore.getState().actions.clearPreviewBufferEverywhere(promotedPreviewBufferId);
           }
+
+          if (buffer.type === "editor") {
+            publishEditorDocumentChange({
+              bufferId,
+              filePath: buffer.path,
+              sourceId: "buffer-store",
+              modelSessionId: `buffer-${bufferId}`,
+              modelVersionId: contentRevision,
+              changes: [],
+              eol: content.includes("\r\n") ? "\r\n" : "\n",
+              isEolChange: false,
+              isFlush: true,
+              isUndoing: false,
+              isRedoing: false,
+              fullContent: content,
+            });
+          }
+        },
+
+        applyBufferContentChanges: (bufferId, batch, markDirty = true) => {
+          const buffer = getBufferById(get().buffers, bufferId);
+          if (!buffer || !isEditorContent(buffer)) {
+            return { accepted: false, synchronized: false, contentRevision: 0 };
+          }
+
+          const lastAppliedVersion = lastAppliedModelVersionByBuffer.get(bufferId);
+          if (
+            lastAppliedVersion?.modelSessionId === batch.modelSessionId &&
+            batch.modelVersionId <= lastAppliedVersion.modelVersionId
+          ) {
+            return {
+              accepted: false,
+              synchronized: true,
+              contentRevision: buffer.contentRevision ?? 0,
+            };
+          }
+
+          // A delta that does not land cleanly is refused rather than guessed at; the editor then
+          // resends the model's full text.
+          const nextContent =
+            batch.fullContent ??
+            (batch.isFlush || batch.isEolChange
+              ? null
+              : applyEditorTextChanges(buffer.content, batch.changes));
+          if (
+            nextContent === null ||
+            (batch.fullContent === undefined &&
+              batch.expectedContentLength !== undefined &&
+              nextContent.length !== batch.expectedContentLength)
+          ) {
+            return {
+              accepted: false,
+              synchronized: false,
+              contentRevision: buffer.contentRevision ?? 0,
+            };
+          }
+
+          const contentRevision = (buffer.contentRevision ?? 0) + 1;
+          let promotedPreviewBufferId: string | null = null;
+          set((state) => {
+            const current = state.buffers.find((item) => item.id === bufferId);
+            if (!current || !isEditorContent(current)) return;
+            current.content = nextContent;
+            current.contentRevision = contentRevision;
+            if (!markDirty) {
+              current.savedContent = nextContent;
+              current.isDirty = false;
+            } else {
+              current.isDirty = nextContent !== current.savedContent;
+              if (current.isPreview && current.isDirty) {
+                current.isPreview = false;
+                promotedPreviewBufferId = current.id;
+              }
+            }
+          });
+          lastAppliedModelVersionByBuffer.set(bufferId, {
+            modelSessionId: batch.modelSessionId,
+            modelVersionId: batch.modelVersionId,
+          });
+
+          if (promotedPreviewBufferId) {
+            paneStore.getState().actions.clearPreviewBufferEverywhere(promotedPreviewBufferId);
+          }
+
+          publishEditorDocumentChange({
+            ...batch,
+            bufferId,
+            filePath: buffer.path,
+          });
+          return { accepted: true, synchronized: true, contentRevision };
         },
 
         updateBufferTokens: (bufferId: string, tokens: TokenEntry[]) => {
@@ -1323,12 +1433,42 @@ const createBufferStore = (workspaceId: string) => {
         },
 
         updateBuffer: (updatedBuffer: PaneContent) => {
+          const currentBuffer = getBufferById(get().buffers, updatedBuffer.id);
+          const contentChanged =
+            currentBuffer?.type === "editor" &&
+            updatedBuffer.type === "editor" &&
+            currentBuffer.content !== updatedBuffer.content;
+          const nextBuffer =
+            currentBuffer?.type === "editor" && updatedBuffer.type === "editor"
+              ? {
+                  ...updatedBuffer,
+                  contentRevision: contentChanged
+                    ? (currentBuffer.contentRevision ?? 0) + 1
+                    : (currentBuffer.contentRevision ?? updatedBuffer.contentRevision ?? 0),
+                }
+              : updatedBuffer;
           set((state) => {
             const index = state.buffers.findIndex((b) => b.id === updatedBuffer.id);
             if (index !== -1) {
-              state.buffers[index] = updatedBuffer;
+              state.buffers[index] = nextBuffer;
             }
           });
+          if (contentChanged && nextBuffer.type === "editor") {
+            publishEditorDocumentChange({
+              bufferId: nextBuffer.id,
+              filePath: nextBuffer.path,
+              sourceId: "buffer-store",
+              modelSessionId: `buffer-${nextBuffer.id}`,
+              modelVersionId: nextBuffer.contentRevision ?? 0,
+              changes: [],
+              eol: nextBuffer.content.includes("\r\n") ? "\r\n" : "\n",
+              isEolChange: false,
+              isFlush: true,
+              isUndoing: false,
+              isRedoing: false,
+              fullContent: nextBuffer.content,
+            });
+          }
         },
 
         handleTabClick: (bufferId: string) => {
diff --git a/src/features/editor/stores/editor-app.store.ts b/src/features/editor/stores/editor-app.store.ts
index 612f7f6a9..babdf8f39 100644
--- a/src/features/editor/stores/editor-app.store.ts
+++ b/src/features/editor/stores/editor-app.store.ts
@@ -11,12 +11,18 @@ import { isEditorContent } from "@/features/panes/types/pane-content.types";
 import { useSettingsStore } from "@/features/settings/stores/settings.store";
 import { createSelectors } from "@/utils/zustand-selectors";
 import { writeFile } from "@/features/file-system/controllers/platform";
-import type { EditorContentChangeOptions, Position, Range } from "../types/editor.types";
+import type {
+  EditorContentChangeOptions,
+  EditorDocumentChangeBatch,
+  EditorDocumentChangeResult,
+  Position,
+  Range,
+} from "../types/editor.types";
 import { getBufferById } from "../utils/buffer-index";
 import { getDirtyWritableEditorBuffers } from "../utils/editor-buffer-selectors";
 import { trackBufferHistoryChange } from "./buffer-history-tracking";
 import { useBufferStore } from "./buffer.store";
-import { queueEditorViewContentChange } from "./view.store";
+import { discardEditorViewContentChange, queueEditorViewContentChange } from "./view.store";
 
 async function recordLocalHistoryBeforeWrite(
   path: string,
@@ -146,7 +152,7 @@ async function saveEditorBufferById(bufferId: string): Promise {
     await recordLocalHistoryBeforeWrite(activeBuffer.path, "save");
     await writeFile(activeBuffer.path, contentToSave);
     const { LspClient } = await import("@/features/editor/lsp/lsp-client");
-    await LspClient.getInstance().notifyDocumentSave(activeBuffer.path, contentToSave);
+    await LspClient.getInstance().notifyDocumentSave(activeBuffer.path);
     markBufferDirty(activeBuffer.id, false);
 
     if (settings.lintOnSave) {
@@ -201,6 +207,12 @@ interface AppState {
 }
 
 interface AppActions {
+  handleDocumentChange: (
+    bufferId: string,
+    batch: EditorDocumentChangeBatch,
+    previousCursorPosition?: Position,
+    previousSelection?: Range,
+  ) => EditorDocumentChangeResult;
   handleContentChange: (
     content: string,
     previousContent?: string,
@@ -229,6 +241,91 @@ export const useEditorAppStore = createSelectors(
         selectionRange: { start: 0, end: 0 },
       },
       actions: {
+        handleDocumentChange: (bufferId, batch, previousCursorPosition, previousSelection) => {
+          const { buffers } = useBufferStore.getState();
+          const { applyBufferContentChanges, markBufferDirty } = useBufferStore.getState().actions;
+          const activeBuffer = getBufferById(buffers, bufferId);
+          if (!activeBuffer || !isEditorContent(activeBuffer)) {
+            return { accepted: false, synchronized: false, contentRevision: 0 };
+          }
+
+          const previousContent = activeBuffer.content;
+          const previousContentRevision = activeBuffer.contentRevision ?? 0;
+          queueEditorViewContentChange(bufferId, previousContentRevision, batch);
+          const collaborationNoteTarget = parseCollaborationNoteBufferPath(activeBuffer.path);
+          const isRemoteFile = activeBuffer.path.startsWith("remote://");
+          const result = applyBufferContentChanges(bufferId, batch, isRemoteFile ? false : true);
+          if (!result.accepted) {
+            discardEditorViewContentChange(bufferId);
+            return result;
+          }
+
+          const updatedBuffer = getBufferById(useBufferStore.getState().buffers, bufferId);
+          if (!updatedBuffer || !isEditorContent(updatedBuffer)) return result;
+
+          trackBufferHistoryChange({
+            bufferId,
+            currentContent: previousContent,
+            nextContent: updatedBuffer.content,
+            previousContent,
+            previousCursorPosition,
+            previousSelection,
+            contentChanges: batch.changes,
+          });
+
+          if (collaborationNoteTarget) {
+            markBufferDirty(bufferId, updatedBuffer.content !== updatedBuffer.savedContent);
+          }
+
+          const { settings } = useSettingsStore.getState();
+          if (
+            !isRemoteFile &&
+            !collaborationNoteTarget &&
+            !activeBuffer.isVirtual &&
+            settings.autoSave
+          ) {
+            const { autoSaveTimeoutId } = get();
+            if (autoSaveTimeoutId) clearTimeout(autoSaveTimeoutId);
+
+            const newTimeoutId = setTimeout(async () => {
+              const latestBuffer = getBufferById(useBufferStore.getState().buffers, bufferId);
+              if (!latestBuffer || !isEditorContent(latestBuffer)) return;
+              const savingRevision = latestBuffer.contentRevision ?? 0;
+              try {
+                useFileWatcherStore.getState().actions.markPendingSave(latestBuffer.path);
+                await recordLocalHistoryBeforeWrite(latestBuffer.path, "auto-save");
+                await writeFile(latestBuffer.path, latestBuffer.content);
+                const currentBuffer = getBufferById(useBufferStore.getState().buffers, bufferId);
+                if (
+                  currentBuffer &&
+                  isEditorContent(currentBuffer) &&
+                  (currentBuffer.contentRevision ?? 0) === savingRevision
+                ) {
+                  markBufferDirty(bufferId, false);
+                }
+
+                const rootFolderPath = useFileSystemStore.getState().rootFolderPath;
+                if (rootFolderPath) {
+                  emitGitChanged({
+                    repoPath: rootFolderPath,
+                    filePath: latestBuffer.path,
+                    scopes: ["working-tree"],
+                    source: "auto-save",
+                  });
+                }
+              } catch (error) {
+                console.error("Error saving file:", error);
+                markBufferDirty(bufferId, true);
+              }
+            }, 150);
+            set((state) => {
+              state.autoSaveTimeoutId = newTimeoutId;
+            });
+          }
+
+          return result;
+        },
+
         handleContentChange: async (
           content: string,
           previousContent?: string,
@@ -246,15 +343,6 @@ export const useEditorAppStore = createSelectors(
           if (!activeBuffer || !isEditorContent(activeBuffer)) return;
           const collaborationNoteTarget = parseCollaborationNoteBufferPath(activeBuffer.path);
 
-          if (!contentAlreadyApplied && activeBufferId && options?.contentChange) {
-            queueEditorViewContentChange(
-              activeBufferId,
-              activeBuffer.content,
-              content,
-              options.contentChange,
-            );
-          }
-
           if (activeBufferId) {
             trackBufferHistoryChange({
               bufferId: activeBufferId,
@@ -291,17 +379,33 @@ export const useEditorAppStore = createSelectors(
               }
 
               const newTimeoutId = setTimeout(async () => {
+                const latestBuffer = getBufferById(
+                  useBufferStore.getState().buffers,
+                  activeBuffer.id,
+                );
+                if (!latestBuffer || !isEditorContent(latestBuffer)) return;
+                const savingRevision = latestBuffer.contentRevision ?? 0;
                 try {
-                  markPendingSave(activeBuffer.path);
-                  await recordLocalHistoryBeforeWrite(activeBuffer.path, "auto-save");
-                  await writeFile(activeBuffer.path, content);
-                  markBufferDirty(activeBuffer.id, false);
+                  markPendingSave(latestBuffer.path);
+                  await recordLocalHistoryBeforeWrite(latestBuffer.path, "auto-save");
+                  await writeFile(latestBuffer.path, latestBuffer.content);
+                  const currentBuffer = getBufferById(
+                    useBufferStore.getState().buffers,
+                    activeBuffer.id,
+                  );
+                  if (
+                    currentBuffer &&
+                    isEditorContent(currentBuffer) &&
+                    (currentBuffer.contentRevision ?? 0) === savingRevision
+                  ) {
+                    markBufferDirty(activeBuffer.id, false);
+                  }
 
                   const rootFolderPath = useFileSystemStore.getState().rootFolderPath;
                   if (rootFolderPath) {
                     emitGitChanged({
                       repoPath: rootFolderPath,
-                      filePath: activeBuffer.path,
+                      filePath: latestBuffer.path,
                       scopes: ["working-tree"],
                       source: "auto-save",
                     });
diff --git a/src/features/editor/stores/history.store.ts b/src/features/editor/stores/history.store.ts
index a6c41007a..8d516430f 100644
--- a/src/features/editor/stores/history.store.ts
+++ b/src/features/editor/stores/history.store.ts
@@ -5,8 +5,11 @@ import type {
   BufferHistory,
   HistoryEntry,
   HistoryState,
+  PatchHistoryEntry,
+  StoredHistoryEntry,
 } from "@/features/editor/types/history.types";
 import { createSelectors } from "@/utils/zustand-selectors";
+import { applyHistoryPatchBatches } from "../history/history-patches";
 
 interface HistoryStoreState {
   bufferHistories: BufferHistory;
@@ -14,7 +17,7 @@ interface HistoryStoreState {
 }
 
 interface HistoryActions {
-  pushHistory: (bufferId: string, entry: HistoryEntry) => void;
+  pushHistory: (bufferId: string, entry: StoredHistoryEntry) => void;
   undo: (bufferId: string, currentEntry?: HistoryEntry) => HistoryEntry | null;
   redo: (bufferId: string, currentEntry?: HistoryEntry) => HistoryEntry | null;
   canUndo: (bufferId: string) => boolean;
@@ -33,7 +36,28 @@ const createDefaultHistoryState = (maxHistorySize = DEFAULT_MAX_HISTORY_SIZE): H
   maxHistorySize,
 });
 
-function cloneHistoryEntry(entry: HistoryEntry): HistoryEntry {
+function isPatchHistoryEntry(entry: StoredHistoryEntry): entry is PatchHistoryEntry {
+  return "kind" in entry && entry.kind === "patch";
+}
+
+function cloneStoredHistoryEntry(entry: StoredHistoryEntry): StoredHistoryEntry {
+  if (isPatchHistoryEntry(entry)) {
+    return {
+      ...entry,
+      patches: entry.patches.map((batch) => ({
+        ...batch,
+        changes: batch.changes.map((change) => ({ ...change })),
+      })),
+      cursorPosition: entry.cursorPosition ? { ...entry.cursorPosition } : undefined,
+      selection: entry.selection
+        ? {
+            start: { ...entry.selection.start },
+            end: { ...entry.selection.end },
+          }
+        : undefined,
+    };
+  }
+
   return {
     ...entry,
     cursorPosition: entry.cursorPosition ? { ...entry.cursorPosition } : undefined,
@@ -46,8 +70,42 @@ function cloneHistoryEntry(entry: HistoryEntry): HistoryEntry {
   };
 }
 
-function getHistoryEntryBytes(entry: HistoryEntry): number {
-  return entry.content.length * 2;
+function cloneHistoryEntry(entry: HistoryEntry): HistoryEntry {
+  return cloneStoredHistoryEntry(entry) as HistoryEntry;
+}
+
+function getHistoryEntryBytes(entry: StoredHistoryEntry): number {
+  if (!isPatchHistoryEntry(entry)) return entry.content.length * 2;
+  return entry.patches.reduce(
+    (total, batch) =>
+      total +
+      batch.changes.reduce(
+        (batchTotal, change) =>
+          batchTotal + (change.beforeText.length + change.afterText.length) * 2 + 16,
+        0,
+      ),
+    0,
+  );
+}
+
+function resolvePatchEntry(
+  entry: PatchHistoryEntry,
+  currentEntry: HistoryEntry,
+  direction: "forward" | "reverse",
+): HistoryEntry | null {
+  const content = applyHistoryPatchBatches(currentEntry.content, entry.patches, direction);
+  if (content === null) return null;
+  return {
+    content,
+    cursorPosition: entry.cursorPosition ? { ...entry.cursorPosition } : undefined,
+    selection: entry.selection
+      ? {
+          start: { ...entry.selection.start },
+          end: { ...entry.selection.end },
+        }
+      : undefined,
+    timestamp: entry.timestamp,
+  };
 }
 
 function trimHistoryToByteBudget(history: HistoryState): void {
@@ -75,7 +133,7 @@ export const useHistoryStore = createSelectors(
       bufferHistories: {},
 
       actions: {
-        pushHistory: (bufferId: string, entry: HistoryEntry) => {
+        pushHistory: (bufferId: string, entry: StoredHistoryEntry) => {
           set((state) => {
             if (!state.bufferHistories[bufferId]) {
               state.bufferHistories[bufferId] = createDefaultHistoryState();
@@ -84,7 +142,12 @@ export const useHistoryStore = createSelectors(
             const history = state.bufferHistories[bufferId];
             const lastEntry = history.past[history.past.length - 1];
 
-            if (lastEntry?.content === entry.content) {
+            if (
+              lastEntry &&
+              !isPatchHistoryEntry(lastEntry) &&
+              !isPatchHistoryEntry(entry) &&
+              lastEntry.content === entry.content
+            ) {
               return;
             }
 
@@ -113,12 +176,26 @@ export const useHistoryStore = createSelectors(
           set((state) => {
             const hist = state.bufferHistories[bufferId];
             if (hist && hist.past.length > 0) {
-              const lastEntry = hist.past.pop();
+              const lastEntry = hist.past[hist.past.length - 1];
               if (lastEntry) {
-                if (currentEntry) {
-                  hist.future.push(cloneHistoryEntry(currentEntry));
+                if (isPatchHistoryEntry(lastEntry)) {
+                  if (!currentEntry) return;
+                  const resolved = resolvePatchEntry(lastEntry, currentEntry, "reverse");
+                  if (!resolved) return;
+                  hist.past.pop();
+                  hist.future.push({
+                    ...cloneStoredHistoryEntry(lastEntry),
+                    cursorPosition: currentEntry.cursorPosition,
+                    selection: currentEntry.selection,
+                  });
+                  entry = resolved;
+                } else {
+                  hist.past.pop();
+                  if (currentEntry) {
+                    hist.future.push(cloneHistoryEntry(currentEntry));
+                  }
+                  entry = cloneHistoryEntry(lastEntry);
                 }
-                entry = cloneHistoryEntry(lastEntry);
                 trimHistoryToByteBudget(hist);
               }
             }
@@ -138,12 +215,26 @@ export const useHistoryStore = createSelectors(
           set((state) => {
             const hist = state.bufferHistories[bufferId];
             if (hist && hist.future.length > 0) {
-              const nextEntry = hist.future.pop();
+              const nextEntry = hist.future[hist.future.length - 1];
               if (nextEntry) {
-                if (currentEntry) {
-                  hist.past.push(cloneHistoryEntry(currentEntry));
+                if (isPatchHistoryEntry(nextEntry)) {
+                  if (!currentEntry) return;
+                  const resolved = resolvePatchEntry(nextEntry, currentEntry, "forward");
+                  if (!resolved) return;
+                  hist.future.pop();
+                  hist.past.push({
+                    ...cloneStoredHistoryEntry(nextEntry),
+                    cursorPosition: currentEntry.cursorPosition,
+                    selection: currentEntry.selection,
+                  });
+                  entry = resolved;
+                } else {
+                  hist.future.pop();
+                  if (currentEntry) {
+                    hist.past.push(cloneHistoryEntry(currentEntry));
+                  }
+                  entry = cloneHistoryEntry(nextEntry);
                 }
-                entry = cloneHistoryEntry(nextEntry);
                 trimHistoryToByteBudget(hist);
               }
             }
diff --git a/src/features/editor/stores/view.store.ts b/src/features/editor/stores/view.store.ts
index bec3bf270..cd454365a 100644
--- a/src/features/editor/stores/view.store.ts
+++ b/src/features/editor/stores/view.store.ts
@@ -2,8 +2,13 @@ import isEqual from "fast-deep-equal";
 import { createWithEqualityFn } from "zustand/traditional";
 import { isEditorContent } from "@/features/panes/types/pane-content.types";
 import { createSelectors } from "@/utils/zustand-selectors";
-import type { EditorTextChange } from "../types/editor.types";
-import { createSparseLineArray, getLargeEditorModeInfo } from "../utils/large-file";
+import type { EditorDocumentChangeBatch, EditorTextChange } from "../types/editor.types";
+import {
+  applyEditorChangesToLargeEditorModeInfo,
+  createSparseLineArray,
+  getLargeEditorModeInfo,
+  type LargeEditorModeInfo,
+} from "../utils/large-file";
 import { useBufferStore } from "./buffer.store";
 
 interface EditorViewState {
@@ -49,7 +54,10 @@ export const useEditorViewStore = createSelectors(
 let previousActiveBufferSnapshot: {
   id: string;
   content: string;
+  contentRevision: number;
+  contentLength: number;
   lines: string[];
+  largeEditorInfo: LargeEditorModeInfo;
 } | null = null;
 
 const INCREMENTAL_LINE_EDIT_THRESHOLD = 1000;
@@ -199,27 +207,46 @@ export function applyEditorTextChangeToLines(
   ];
 }
 
+export function applyEditorTextChangesToLines(
+  previousLines: string[],
+  changes: readonly EditorTextChange[],
+): string[] | null {
+  let lines = previousLines;
+  const descendingChanges = [...changes].sort(
+    (left, right) =>
+      (right.startLine ?? 0) - (left.startLine ?? 0) ||
+      (right.startColumn ?? 0) - (left.startColumn ?? 0),
+  );
+  for (const change of descendingChanges) {
+    const nextLines = applyEditorTextChangeToLines(lines, change);
+    if (!nextLines) return null;
+    lines = nextLines;
+  }
+  return lines;
+}
+
 interface PendingEditorViewContentChange {
-  previousContent: string;
-  nextContent: string;
-  change: EditorTextChange;
+  previousContentRevision: number;
+  batch: EditorDocumentChangeBatch;
 }
 
 const pendingEditorViewContentChanges = new Map();
 
 export function queueEditorViewContentChange(
   bufferId: string,
-  previousContent: string,
-  nextContent: string,
-  change: EditorTextChange,
+  previousContentRevision: number,
+  batch: EditorDocumentChangeBatch,
 ): void {
   pendingEditorViewContentChanges.set(bufferId, {
-    previousContent,
-    nextContent,
-    change,
+    previousContentRevision,
+    batch,
   });
 }
 
+export function discardEditorViewContentChange(bufferId: string): void {
+  pendingEditorViewContentChanges.delete(bufferId);
+}
+
 // Subscribe to buffer changes and update computed values
 useBufferStore.subscribe((state) => {
   const activeBuffer = state.actions.getActiveBuffer();
@@ -229,18 +256,37 @@ useBufferStore.subscribe((state) => {
     if (
       previousSnapshot &&
       previousSnapshot.id === activeBuffer.id &&
+      previousSnapshot.contentRevision === (activeBuffer.contentRevision ?? 0) &&
       previousSnapshot.content === activeBuffer.content
     ) {
       return;
     }
 
-    const largeEditorInfo = getLargeEditorModeInfo(activeBuffer.content);
+    const pendingContentChange = pendingEditorViewContentChanges.get(activeBuffer.id);
+    pendingEditorViewContentChanges.delete(activeBuffer.id);
+    const canApplyPendingChange =
+      previousSnapshot?.id === activeBuffer.id &&
+      pendingContentChange?.previousContentRevision === previousSnapshot.contentRevision &&
+      !pendingContentChange.batch.isEolChange &&
+      !pendingContentChange.batch.isFlush;
+    const incrementalLargeEditorInfo = canApplyPendingChange
+      ? applyEditorChangesToLargeEditorModeInfo(
+          previousSnapshot.contentLength,
+          previousSnapshot.largeEditorInfo,
+          pendingContentChange.batch.changes,
+        )
+      : null;
+    const largeEditorInfo =
+      incrementalLargeEditorInfo ?? getLargeEditorModeInfo(activeBuffer.content);
     if (largeEditorInfo.largeContentMode) {
       const lines: string[] = [];
       previousActiveBufferSnapshot = {
         id: activeBuffer.id,
         content: activeBuffer.content,
+        contentRevision: activeBuffer.contentRevision ?? 0,
+        contentLength: activeBuffer.content.length,
         lines,
+        largeEditorInfo,
       };
       useEditorViewStore.setState({
         lines,
@@ -250,25 +296,21 @@ useBufferStore.subscribe((state) => {
     }
 
     const previousLines = previousSnapshot?.id === activeBuffer.id ? previousSnapshot.lines : [""];
-    const pendingContentChange = pendingEditorViewContentChanges.get(activeBuffer.id);
-    pendingEditorViewContentChanges.delete(activeBuffer.id);
-    const changedLines =
-      previousSnapshot?.id === activeBuffer.id &&
-      pendingContentChange?.previousContent === previousSnapshot.content &&
-      pendingContentChange.nextContent === activeBuffer.content
-        ? applyEditorTextChangeToLines(previousLines, pendingContentChange.change)
-        : null;
+    const changedLines = canApplyPendingChange
+      ? applyEditorTextChangesToLines(previousLines, pendingContentChange.batch.changes)
+      : null;
     const lines =
       previousSnapshot?.id === activeBuffer.id
-        ? (changedLines ??
-          applyIncrementalLineEdit(previousSnapshot.content, activeBuffer.content, previousLines) ??
-          activeBuffer.content.split("\n"))
+        ? (changedLines ?? activeBuffer.content.split("\n"))
         : activeBuffer.content.split("\n");
 
     previousActiveBufferSnapshot = {
       id: activeBuffer.id,
       content: activeBuffer.content,
+      contentRevision: activeBuffer.contentRevision ?? 0,
+      contentLength: activeBuffer.content.length,
       lines,
+      largeEditorInfo,
     };
 
     useEditorViewStore.setState({
diff --git a/src/features/editor/tests/buffer-history-tracking.test.ts b/src/features/editor/tests/buffer-history-tracking.test.ts
index dd43a94d6..9c7bb4032 100644
--- a/src/features/editor/tests/buffer-history-tracking.test.ts
+++ b/src/features/editor/tests/buffer-history-tracking.test.ts
@@ -1,6 +1,7 @@
 import { beforeEach, describe, expect, it } from "vite-plus/test";
 import {
   cleanupBufferHistoryTracking,
+  flushPendingBufferHistory,
   trackBufferHistoryChange,
   trackImmediateBufferHistoryChange,
 } from "@/features/editor/stores/buffer-history-tracking";
@@ -66,4 +67,53 @@ describe("buffer history tracking", () => {
       .actions.redo(BUFFER_ID, currentEntry("alpha\nbeta"));
     expect(redoEntry?.content).toBe("alpha\nbeta\nbeta");
   });
+
+  it("stores grouped typing as patches and preserves undo and redo", () => {
+    trackBufferHistoryChange({
+      bufferId: BUFFER_ID,
+      currentContent: "one",
+      nextContent: "one!",
+      previousContent: "one",
+      contentChanges: [
+        {
+          rangeOffset: 3,
+          rangeLength: 0,
+          text: "!",
+          startLine: 0,
+          startColumn: 3,
+          endLine: 0,
+          endColumn: 3,
+        },
+      ],
+    });
+    trackBufferHistoryChange({
+      bufferId: BUFFER_ID,
+      currentContent: "one!",
+      nextContent: "one!!",
+      previousContent: "one!",
+      contentChanges: [
+        {
+          rangeOffset: 4,
+          rangeLength: 0,
+          text: "!",
+          startLine: 0,
+          startColumn: 4,
+          endLine: 0,
+          endColumn: 4,
+        },
+      ],
+    });
+    const pending = useHistoryStore.getState().actions.getHistoryState(BUFFER_ID)?.past ?? [];
+    expect(pending).toHaveLength(0);
+
+    flushPendingBufferHistory(BUFFER_ID, "one!!");
+    const stored = useHistoryStore.getState().actions.getHistoryState(BUFFER_ID)?.past[0];
+    expect(stored).toMatchObject({ kind: "patch", beforeLength: 3, afterLength: 5 });
+    expect("content" in (stored ?? {})).toBe(false);
+
+    const undoEntry = useHistoryStore.getState().actions.undo(BUFFER_ID, currentEntry("one!!"));
+    expect(undoEntry?.content).toBe("one");
+    const redoEntry = useHistoryStore.getState().actions.redo(BUFFER_ID, currentEntry("one"));
+    expect(redoEntry?.content).toBe("one!!");
+  });
 });
diff --git a/src/features/editor/tests/editor-document-changes.test.ts b/src/features/editor/tests/editor-document-changes.test.ts
new file mode 100644
index 000000000..712621e89
--- /dev/null
+++ b/src/features/editor/tests/editor-document-changes.test.ts
@@ -0,0 +1,124 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import type { EditorDocumentChangeBatch } from "../types/editor.types";
+
+const storage = new Map();
+
+describe("editor document changes", () => {
+  beforeEach(() => {
+    vi.stubGlobal("localStorage", {
+      getItem: (key: string) => storage.get(key) ?? null,
+      setItem: (key: string, value: string) => storage.set(key, value),
+      removeItem: (key: string) => storage.delete(key),
+    });
+    vi.stubGlobal("window", {
+      __TAURI_INTERNALS__: {
+        invoke: vi.fn().mockResolvedValue([]),
+        metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } },
+      },
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    });
+  });
+
+  afterEach(async () => {
+    const { useBufferStore } = await import("../stores/buffer.store");
+    useBufferStore.setState({ buffers: [], activeBufferId: null });
+    storage.clear();
+    vi.unstubAllGlobals();
+  });
+
+  it("applies a UTF-16 delta once across duplicate split-pane delivery", async () => {
+    const { useBufferStore } = await import("../stores/buffer.store");
+    const actions = useBufferStore.getState().actions;
+    const id = actions.openContent({
+      type: "editor",
+      path: "/workspace/unicode.ts",
+      name: "unicode.ts",
+      content: "alpha😀\r\nbeta",
+    });
+    const batch: EditorDocumentChangeBatch = {
+      sourceId: "pane-a",
+      modelSessionId: "shared-model",
+      modelVersionId: 2,
+      changes: [
+        {
+          rangeOffset: 5,
+          rangeLength: 2,
+          text: "Ω",
+          startLine: 0,
+          startColumn: 5,
+          endLine: 0,
+          endColumn: 7,
+        },
+      ],
+      eol: "\r\n",
+      isEolChange: false,
+      isFlush: false,
+      isUndoing: false,
+      isRedoing: false,
+    };
+
+    expect(actions.applyBufferContentChanges(id, batch)).toMatchObject({
+      accepted: true,
+      synchronized: true,
+      contentRevision: 1,
+    });
+    expect(actions.applyBufferContentChanges(id, { ...batch, sourceId: "pane-b" })).toMatchObject({
+      accepted: false,
+      synchronized: true,
+      contentRevision: 1,
+    });
+    const buffer = useBufferStore.getState().buffers.find((item) => item.id === id);
+    expect(buffer).toMatchObject({ content: "alphaΩ\r\nbeta", contentRevision: 1, isDirty: true });
+  });
+
+  it("rejects invalid and out-of-order model changes without changing content", async () => {
+    const { useBufferStore } = await import("../stores/buffer.store");
+    const actions = useBufferStore.getState().actions;
+    const id = actions.openContent({
+      type: "editor",
+      path: "/workspace/file.ts",
+      name: "file.ts",
+      content: "abc",
+    });
+    const batch: EditorDocumentChangeBatch = {
+      sourceId: "pane",
+      modelSessionId: "model",
+      modelVersionId: 4,
+      changes: [
+        {
+          rangeOffset: 3,
+          rangeLength: 0,
+          text: "d",
+          startLine: 0,
+          startColumn: 3,
+          endLine: 0,
+          endColumn: 3,
+        },
+      ],
+      eol: "\n",
+      isEolChange: false,
+      isFlush: false,
+      isUndoing: false,
+      isRedoing: false,
+    };
+    expect(actions.applyBufferContentChanges(id, batch).accepted).toBe(true);
+    expect(actions.applyBufferContentChanges(id, { ...batch, modelVersionId: 3 })).toMatchObject({
+      accepted: false,
+      synchronized: true,
+    });
+    expect(
+      actions.applyBufferContentChanges(id, {
+        ...batch,
+        modelSessionId: "new-model",
+        modelVersionId: 1,
+        changes: [{ ...batch.changes[0], rangeOffset: 99 }],
+      }),
+    ).toMatchObject({ accepted: false, synchronized: false });
+    expect(useBufferStore.getState().buffers.find((item) => item.id === id)).toMatchObject({
+      content: "abcd",
+      contentRevision: 1,
+    });
+  });
+});
diff --git a/src/features/editor/tests/history-store.test.ts b/src/features/editor/tests/history-store.test.ts
index 631dfb285..e5c5f4e0a 100644
--- a/src/features/editor/tests/history-store.test.ts
+++ b/src/features/editor/tests/history-store.test.ts
@@ -63,7 +63,9 @@ describe("history store", () => {
     pushHistory("buffer-1", entry(`${snapshot}3`));
 
     expect(
-      getHistoryState("buffer-1")?.past.map(({ content }) => content[content.length - 1]),
+      getHistoryState("buffer-1")?.past.map((storedEntry) =>
+        "content" in storedEntry ? storedEntry.content[storedEntry.content.length - 1] : undefined,
+      ),
     ).toEqual(["2", "3"]);
   });
 });
diff --git a/src/features/editor/tests/lsp-document-sync.test.ts b/src/features/editor/tests/lsp-document-sync.test.ts
new file mode 100644
index 000000000..d80778bf1
--- /dev/null
+++ b/src/features/editor/tests/lsp-document-sync.test.ts
@@ -0,0 +1,263 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { invoke } from "@tauri-apps/api/core";
+import { LspClient } from "../lsp/lsp-client";
+import { useBufferStore } from "../stores/buffer.store";
+import { publishEditorDocumentChange } from "../services/editor-document-events";
+import type { EditorDocumentChangeEvent } from "../types/editor.types";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
+vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockResolvedValue(() => undefined) }));
+vi.mock("@/extensions/registry/extension-registry", () => ({
+  extensionRegistry: { getLanguageId: () => "typescript" },
+}));
+
+const filePath = "/workspace/example.ts";
+
+function event(version: number, offset: number, text: string): EditorDocumentChangeEvent {
+  return {
+    bufferId: "buffer-1",
+    filePath,
+    sourceId: "editor-1",
+    modelSessionId: "model-1",
+    modelVersionId: version,
+    changes: [
+      {
+        rangeOffset: offset,
+        rangeLength: 0,
+        text,
+        startLine: 0,
+        startColumn: offset,
+        endLine: 0,
+        endColumn: offset,
+      },
+    ],
+    eol: "\n",
+    isEolChange: false,
+    isFlush: false,
+    isUndoing: false,
+    isRedoing: false,
+  };
+}
+
+describe("LSP incremental document synchronization", () => {
+  const client = LspClient.getInstance();
+  const state = client as unknown as {
+    openDocuments: Set;
+    backendOpenedDocuments: Set;
+    closingDocuments: Set;
+    openingDocuments: Map>;
+    documentLifecycleGenerations: Map;
+    documentVersions: Map;
+    documentChangeQueues: Map;
+    documentChangeTimers: Map>;
+    documentChangeSendChains: Map>;
+    documentChangeRetries: Map;
+    documentsNeedingResync: Set;
+  };
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.mocked(invoke).mockReset();
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_change_batch") return 2;
+      return null;
+    });
+    state.openDocuments.clear();
+    state.openDocuments.add(filePath);
+    state.backendOpenedDocuments.clear();
+    state.backendOpenedDocuments.add(filePath);
+    state.closingDocuments.clear();
+    state.openingDocuments.clear();
+    state.documentLifecycleGenerations.clear();
+    state.documentVersions.clear();
+    state.documentVersions.set(filePath, 1);
+    state.documentChangeQueues.clear();
+    state.documentChangeTimers.clear();
+    state.documentChangeSendChains.clear();
+    state.documentChangeRetries.clear();
+    state.documentsNeedingResync.clear();
+    useBufferStore.setState({
+      buffers: [
+        {
+          id: "buffer-1",
+          type: "editor",
+          path: filePath,
+          name: "example.ts",
+          content: "ab",
+          savedContent: "",
+          isDirty: true,
+          isVirtual: false,
+          isPreview: false,
+          isPinned: false,
+          isActive: true,
+          language: "typescript",
+          tokens: [],
+        },
+      ],
+    });
+  });
+
+  afterEach(() => vi.useRealTimers());
+
+  it("coalesces consecutive editor events into one IPC request without full content", async () => {
+    publishEditorDocumentChange(event(2, 0, "a"));
+    publishEditorDocumentChange(event(3, 1, "b"));
+
+    await vi.advanceTimersByTimeAsync(40);
+
+    expect(invoke).toHaveBeenCalledTimes(1);
+    expect(invoke).toHaveBeenCalledWith("lsp_document_change_batch", {
+      filePath,
+      batches: [
+        expect.objectContaining({ modelVersionId: 2, fullContent: undefined }),
+        expect.objectContaining({ modelVersionId: 3, fullContent: undefined }),
+      ],
+    });
+    expect(state.documentVersions.get(filePath)).toBe(2);
+  });
+
+  it("flushes queued edits before a document request", async () => {
+    publishEditorDocumentChange(event(2, 0, "a"));
+
+    await client.getHover(filePath, 0, 1);
+
+    expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([
+      "lsp_document_change_batch",
+      "lsp_get_hover",
+    ]);
+  });
+
+  it("keeps edits queued until a slow document open completes", async () => {
+    state.openDocuments.clear();
+    state.backendOpenedDocuments.clear();
+    let finishOpen: (() => void) | undefined;
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_open") {
+        await new Promise((resolve) => {
+          finishOpen = resolve;
+        });
+      }
+      if (command === "lsp_document_change_batch") return 2;
+      return null;
+    });
+
+    const opening = client.notifyDocumentOpen(filePath, "");
+    publishEditorDocumentChange(event(2, 0, "a"));
+    await vi.advanceTimersByTimeAsync(40);
+    expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual(["lsp_document_open"]);
+
+    finishOpen?.();
+    await opening;
+    expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([
+      "lsp_document_open",
+      "lsp_document_change_batch",
+    ]);
+  });
+
+  it("retries a failed change batch without dropping edits", async () => {
+    let attempts = 0;
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_change_batch" && attempts++ === 0) {
+        throw new Error("temporary IPC failure");
+      }
+      if (command === "lsp_document_change_batch") return 2;
+      return null;
+    });
+    publishEditorDocumentChange(event(2, 0, "a"));
+
+    await vi.advanceTimersByTimeAsync(40);
+    await vi.advanceTimersByTimeAsync(80);
+
+    expect(
+      vi.mocked(invoke).mock.calls.filter(([command]) => command === "lsp_document_change_batch"),
+    ).toHaveLength(2);
+  });
+
+  it("closes a backend document that finishes opening after close starts", async () => {
+    state.openDocuments.clear();
+    state.backendOpenedDocuments.clear();
+    let finishOpen: (() => void) | undefined;
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_open") {
+        await new Promise((resolve) => {
+          finishOpen = resolve;
+        });
+      }
+      return null;
+    });
+
+    const opening = client.notifyDocumentOpen(filePath, "");
+    await vi.advanceTimersByTimeAsync(0);
+    const closing = client.notifyDocumentClose(filePath);
+    finishOpen?.();
+    await Promise.all([opening, closing]);
+
+    expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([
+      "lsp_document_open",
+      "lsp_document_close",
+    ]);
+    expect(state.openDocuments.has(filePath)).toBe(false);
+  });
+
+  it("replaces the server's copy with the editor text once retries run out", async () => {
+    let failures = 3;
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_change_batch" && failures-- > 0) {
+        throw new Error("mirror rejected the change");
+      }
+      if (command === "lsp_document_change_batch") return 3;
+      return null;
+    });
+    publishEditorDocumentChange(event(2, 0, "a"));
+
+    await vi.advanceTimersByTimeAsync(40 + 80 + 160 + 240);
+
+    const batches = vi
+      .mocked(invoke)
+      .mock.calls.filter(([command]) => command === "lsp_document_change_batch")
+      .map(([, args]) => (args as { batches: unknown[] }).batches);
+    expect(batches).toHaveLength(4);
+    expect(batches[3]).toEqual([
+      expect.objectContaining({ isFlush: true, fullContent: "ab", changes: [] }),
+    ]);
+    expect(state.documentsNeedingResync.has(filePath)).toBe(false);
+    expect(state.documentVersions.get(filePath)).toBe(3);
+  });
+
+  it("still answers requests while the server cannot take edits", async () => {
+    vi.mocked(invoke).mockImplementation(async (command) => {
+      if (command === "lsp_document_change_batch") throw new Error("server gone");
+      if (command === "lsp_get_hover") return { contents: "hover" };
+      return null;
+    });
+    publishEditorDocumentChange(event(2, 0, "a"));
+
+    await expect(client.getHover(filePath, 0, 1)).resolves.toEqual({ contents: "hover" });
+    expect(state.documentsNeedingResync.has(filePath)).toBe(true);
+
+    vi.mocked(invoke).mockImplementation(async (command) =>
+      command === "lsp_document_change_batch" ? 5 : null,
+    );
+    publishEditorDocumentChange(event(3, 1, "b"));
+    await vi.advanceTimersByTimeAsync(40);
+
+    const batchCalls = vi
+      .mocked(invoke)
+      .mock.calls.filter(([command]) => command === "lsp_document_change_batch");
+    const lastBatch = batchCalls[batchCalls.length - 1]?.[1] as { batches: unknown[] };
+    expect(lastBatch.batches).toEqual([expect.objectContaining({ fullContent: "ab" })]);
+    expect(state.documentsNeedingResync.has(filePath)).toBe(false);
+  });
+
+  it("opens a document with the store's text rather than a stale render", async () => {
+    state.openDocuments.clear();
+    state.backendOpenedDocuments.clear();
+
+    await client.notifyDocumentOpen(filePath, "stale");
+
+    expect(invoke).toHaveBeenCalledWith(
+      "lsp_document_open",
+      expect.objectContaining({ filePath, content: "ab" }),
+    );
+  });
+});
diff --git a/src/features/editor/tests/markdown-code-highlight.test.ts b/src/features/editor/tests/markdown-code-highlight.test.ts
index 28bd23c7b..d47225d5c 100644
--- a/src/features/editor/tests/markdown-code-highlight.test.ts
+++ b/src/features/editor/tests/markdown-code-highlight.test.ts
@@ -1,5 +1,4 @@
 import { afterEach, describe, expect, it, vi } from "vite-plus/test";
-import { tokenizeCode } from "../lib/wasm-parser/tokenizer";
 import { yieldToMain } from "@/utils/yield-to-main";
 
 vi.mock("@/utils/yield-to-main", () => ({ yieldToMain: vi.fn(async () => {}) }));
@@ -23,8 +22,10 @@ vi.mock("@/features/editor/lib/wasm-parser/extension-assets", () => ({
   })),
 }));
 
-vi.mock("@/features/editor/lib/wasm-parser/tokenizer", () => ({
-  tokenizeCode: vi.fn(async () => []),
+const { tokenize } = vi.hoisted(() => ({ tokenize: vi.fn() }));
+
+vi.mock("@/features/editor/lib/wasm-parser/tokenizer-worker-client", () => ({
+  tokenizerWorkerClient: { tokenize },
 }));
 
 import { highlightMarkdownCodeBlocks } from "../markdown/code-highlight";
@@ -35,13 +36,13 @@ describe("highlightMarkdownCodeBlocks", () => {
     let running = 0;
     let peak = 0;
     vi.spyOn(performance, "now").mockImplementation(() => clock);
-    vi.mocked(tokenizeCode).mockImplementation(async () => {
+    tokenize.mockImplementation(async () => {
       running++;
       peak = Math.max(peak, running);
       await Promise.resolve();
       clock += 10;
       running--;
-      return [];
+      return { tokens: [], normalizedText: "" };
     });
     const html = await highlightMarkdownCodeBlocks(
       '

Before

first = 91

Between

second = 92

After

', @@ -60,6 +61,7 @@ describe("highlightMarkdownCodeBlocks", () => { }); it("uses fallback highlighting for R, Python, and SQL preview code blocks", async () => { + tokenize.mockResolvedValue({ tokens: [], normalizedText: "" }); const html = await highlightMarkdownCodeBlocks( [ '
library(dplyr)\nvalue <- 1
', @@ -74,5 +76,38 @@ describe("highlightMarkdownCodeBlocks", () => { expect(html).toContain('class="language-r"'); expect(html).toContain('class="language-python"'); expect(html).toContain('class="language-sql"'); + expect(tokenize).toHaveBeenCalledTimes(3); + expect(tokenize).toHaveBeenCalledWith( + expect.objectContaining({ mode: "full", content: expect.any(String) }), + ); + }); + + it("renders worker token offsets without changing the code text", async () => { + tokenize.mockResolvedValueOnce({ + normalizedText: "const answer = 42;", + tokens: [ + { + type: "token-keyword", + startIndex: 0, + endIndex: 5, + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 5 }, + }, + ], + }); + + const html = await highlightMarkdownCodeBlocks( + '
const answer = 42;
', + "markdown-test", + ); + + expect(html).toContain('const answer = 42;'); + expect(tokenize).toHaveBeenLastCalledWith( + expect.objectContaining({ + bufferId: "markdown-test:0", + latestKey: "markdown-test:0", + languageId: "typescript", + }), + ); }); }); diff --git a/src/features/editor/tests/monaco-buffer-sync.test.ts b/src/features/editor/tests/monaco-buffer-sync.test.ts new file mode 100644 index 000000000..d222c21c0 --- /dev/null +++ b/src/features/editor/tests/monaco-buffer-sync.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Range } from "monaco-editor/esm/vs/editor/common/core/range.js"; +import { PieceTreeTextBufferBuilder } from "monaco-editor/esm/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.js"; +import { modelMatchesContent } from "../engines/monaco/content-sync"; +import { + deliverModelContentChange, + type ModelContentChangeEvent, +} from "../engines/monaco/document-change-batch"; +import type { EditorDocumentChangeBatch, EditorDocumentChangeResult } from "../types/editor.types"; + +const storage = new Map(); +const DEFAULT_EOL_LF = 1; + +/** + * Monaco's own text buffer, which is what normalizes line endings and drops the BOM when a model + * is created, and what produces the change offsets a model event carries. + */ +function createMonacoText(raw: string) { + const builder = new PieceTreeTextBufferBuilder(); + builder.acceptChunk(raw); + const { textBuffer } = builder.finish(true).create(DEFAULT_EOL_LF); + let versionId = 1; + const model = { + getValue: () => textBuffer.getLinesContent().join(textBuffer.getEOL()), + getValueLength: () => textBuffer.getLength(), + }; + const edit = ( + [startLine, startColumn, endLine, endColumn]: [number, number, number, number], + text: string, + ): ModelContentChangeEvent => { + const { changes } = textBuffer.applyEdits( + [{ range: new Range(startLine, startColumn, endLine, endColumn), text }], + false, + false, + ); + versionId += 1; + return { + changes, + versionId, + eol: textBuffer.getEOL(), + isEolChange: false, + isFlush: false, + isUndoing: false, + isRedoing: false, + }; + }; + return { model, edit }; +} + +async function openEditor(raw: string) { + const { useBufferStore } = await import("../stores/buffer.store"); + const actions = useBufferStore.getState().actions; + const bufferId = actions.openContent({ + type: "editor", + path: "/workspace/file.ts", + name: "file.ts", + content: raw, + }); + const monaco = createMonacoText(raw); + let bufferMatchesModel = modelMatchesContent(monaco.model, raw); + const sent: EditorDocumentChangeBatch[] = []; + + const type = (range: [number, number, number, number], text: string) => { + const delivered = deliverModelContentChange({ + event: monaco.edit(range, text), + model: monaco.model, + sourceId: "editor", + modelSessionId: "model", + bufferMatchesModel, + apply: (batch) => { + sent.push(batch); + return actions.applyBufferContentChanges(bufferId, batch); + }, + }); + bufferMatchesModel = delivered.bufferMatchesModel; + return delivered.result; + }; + const bufferContent = () => { + const buffer = useBufferStore.getState().buffers.find((item) => item.id === bufferId); + return buffer?.type === "editor" ? buffer.content : null; + }; + return { model: monaco.model, type, bufferContent, sent }; +} + +describe("Monaco edits reaching the buffer", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + vi.stubGlobal("window", { + __TAURI_INTERNALS__: { + invoke: vi.fn().mockResolvedValue([]), + metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + }); + + afterEach(async () => { + const { useBufferStore } = await import("../stores/buffer.store"); + useBufferStore.setState({ buffers: [], activeBufferId: null }); + storage.clear(); + vi.unstubAllGlobals(); + }); + + it("keeps a CRLF file in step using deltas only", async () => { + const editor = await openEditor("first\r\nsecond\r\nthird"); + + editor.type([2, 1, 2, 1], "new "); + editor.type([3, 6, 3, 6], "\nfourth"); + editor.type([1, 3, 2, 3], ""); + + expect(editor.bufferContent()).toBe(editor.model.getValue()); + expect(editor.bufferContent()).toBe("fiw second\r\nthird\r\nfourth"); + expect(editor.sent.every((batch) => batch.fullContent === undefined)).toBe(true); + }); + + it("does not shift edits by the BOM Monaco strips", async () => { + const editor = await openEditor("alpha\r\nbeta"); + + editor.type([2, 1, 2, 1], "X"); + editor.type([1, 6, 1, 6], "!"); + + expect(editor.bufferContent()).toBe(editor.model.getValue()); + expect(editor.bufferContent()).toBe("alpha!\r\nXbeta"); + expect(editor.sent[0]?.fullContent).toBe("alpha\r\nXbeta"); + expect(editor.sent[1]?.fullContent).toBeUndefined(); + }); + + it("takes Monaco's normalized text for mixed and lone-CR line endings", async () => { + const editor = await openEditor("one\r\ntwo\nthree\rfour\n"); + + editor.type([3, 1, 3, 6], "3"); + editor.type([4, 5, 5, 1], ""); + editor.type([1, 1, 1, 1], "zero\r\n"); + + expect(editor.bufferContent()).toBe(editor.model.getValue()); + expect(editor.bufferContent()).toBe("zero\none\ntwo\n3\nfour"); + expect(editor.sent.map((batch) => batch.fullContent !== undefined)).toEqual([ + true, + false, + false, + ]); + }); + + it("falls back to the model's text when a delta would not land cleanly", () => { + const text = createMonacoText("abc"); + const applied: EditorDocumentChangeBatch[] = []; + const apply = (batch: EditorDocumentChangeBatch): EditorDocumentChangeResult => { + applied.push(batch); + return batch.fullContent === undefined + ? { accepted: false, synchronized: false, contentRevision: 1 } + : { accepted: true, synchronized: true, contentRevision: 2 }; + }; + + const delivered = deliverModelContentChange({ + event: text.edit([1, 4, 1, 4], "d"), + model: text.model, + sourceId: "editor", + modelSessionId: "model", + bufferMatchesModel: true, + apply, + }); + + expect(delivered).toEqual({ + result: { accepted: true, synchronized: true, contentRevision: 2 }, + bufferMatchesModel: true, + }); + expect(applied.map((batch) => batch.fullContent)).toEqual([undefined, "abcd"]); + expect(applied[0]?.expectedContentLength).toBe(4); + }); + + it("refuses a delta whose result length disagrees with the model", async () => { + const { useBufferStore } = await import("../stores/buffer.store"); + const actions = useBufferStore.getState().actions; + const bufferId = actions.openContent({ + type: "editor", + path: "/workspace/drift.ts", + name: "drift.ts", + content: "\r\nabc", + }); + + const result = actions.applyBufferContentChanges(bufferId, { + sourceId: "editor", + modelSessionId: "model", + modelVersionId: 2, + changes: [ + { + rangeOffset: 1, + rangeLength: 0, + text: "x", + startLine: 1, + startColumn: 0, + endLine: 1, + endColumn: 0, + }, + ], + eol: "\n", + isEolChange: false, + isFlush: false, + isUndoing: false, + isRedoing: false, + expectedContentLength: 5, + }); + + expect(result).toMatchObject({ accepted: false, synchronized: false }); + expect(useBufferStore.getState().buffers.find((item) => item.id === bufferId)).toMatchObject({ + content: "\r\nabc", + contentRevision: 0, + }); + }); +}); diff --git a/src/features/editor/tests/monaco-content-sync.test.ts b/src/features/editor/tests/monaco-content-sync.test.ts index 2071983b9..fe97d68f4 100644 --- a/src/features/editor/tests/monaco-content-sync.test.ts +++ b/src/features/editor/tests/monaco-content-sync.test.ts @@ -1,32 +1,24 @@ import { describe, expect, it } from "vite-plus/test"; -import { - consumeLocalContentSnapshot, - rememberLocalContentSnapshot, -} from "../engines/monaco/content-sync"; +import { isExternalModelUpdate, runWithExternalModelUpdate } from "../engines/monaco/content-sync"; describe("Monaco content sync", () => { - it("recognizes stale local content echoes without applying them twice", () => { - const snapshots: string[] = []; - - rememberLocalContentSnapshot(snapshots, "a"); - rememberLocalContentSnapshot(snapshots, "ab"); - - expect(consumeLocalContentSnapshot(snapshots, "a")).toBe(true); - expect(snapshots).toEqual(["ab"]); - expect(consumeLocalContentSnapshot(snapshots, "ab")).toBe(true); - expect(snapshots).toEqual([]); + it("marks nested external updates without retaining content snapshots", () => { + const model = {}; + runWithExternalModelUpdate(model, () => { + expect(isExternalModelUpdate(model)).toBe(true); + runWithExternalModelUpdate(model, () => expect(isExternalModelUpdate(model)).toBe(true)); + expect(isExternalModelUpdate(model)).toBe(true); + }); + expect(isExternalModelUpdate(model)).toBe(false); }); - it("keeps only recent local snapshots", () => { - const snapshots: string[] = []; - - for (let index = 0; index < 10; index++) { - rememberLocalContentSnapshot(snapshots, `content-${index}`); - } - - expect(consumeLocalContentSnapshot(snapshots, "content-0")).toBe(false); - expect(consumeLocalContentSnapshot(snapshots, "content-1")).toBe(false); - expect(consumeLocalContentSnapshot(snapshots, "content-2")).toBe(true); - expect(consumeLocalContentSnapshot(snapshots, "content-9")).toBe(true); + it("clears the marker after an exception", () => { + const model = {}; + expect(() => + runWithExternalModelUpdate(model, () => { + throw new Error("failed"); + }), + ).toThrow(); + expect(isExternalModelUpdate(model)).toBe(false); }); }); diff --git a/src/features/editor/tests/monaco-model-lifecycle.test.ts b/src/features/editor/tests/monaco-model-lifecycle.test.ts index b3432154b..b1998faf5 100644 --- a/src/features/editor/tests/monaco-model-lifecycle.test.ts +++ b/src/features/editor/tests/monaco-model-lifecycle.test.ts @@ -95,4 +95,16 @@ describe("Monaco model lifecycle", () => { vi.advanceTimersByTime(5_000); expect(model.dispose).toHaveBeenCalledTimes(1); }); + + it("keeps one change session for every view of a shared model", () => { + const model = createTextModel(); + getModel.mockReturnValue(null); + createModel.mockReturnValue(model); + const first = acquireMonacoModel("one", "text", uri as never); + const second = acquireMonacoModel("one", "text", uri as never); + expect(second.sessionId).toBe(first.sessionId); + + first.release(); + second.release(); + }); }); diff --git a/src/features/editor/tests/tokenizer-range.test.ts b/src/features/editor/tests/tokenizer-range.test.ts new file mode 100644 index 000000000..5701d5ec8 --- /dev/null +++ b/src/features/editor/tests/tokenizer-range.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; +import { filterTokensToRange, intersectsTokenizerRange } from "../lib/wasm-parser/tokenizer-range"; +import type { HighlightToken } from "../types/wasm-parser/wasm-parser.types"; + +const range = { + startPosition: { row: 10, column: 0 }, + endPosition: { row: 20, column: Number.MAX_SAFE_INTEGER }, + startIndex: 100, + endIndex: 200, +}; + +describe("tokenizer range filtering", () => { + it("keeps intersecting captures and injections at both range edges", () => { + expect(intersectsTokenizerRange({ startIndex: 90, endIndex: 101 }, range)).toBe(true); + expect(intersectsTokenizerRange({ startIndex: 199, endIndex: 220 }, range)).toBe(true); + expect(intersectsTokenizerRange({ startIndex: 0, endIndex: 100 }, range)).toBe(false); + expect(intersectsTokenizerRange({ startIndex: 200, endIndex: 250 }, range)).toBe(false); + }); + + it("filters overlay tokens to the requested range", () => { + const token = (startIndex: number, endIndex: number): HighlightToken => ({ + type: "token-variable", + startIndex, + endIndex, + startPosition: { row: 0, column: startIndex }, + endPosition: { row: 0, column: endIndex }, + }); + + expect(filterTokensToRange([token(10, 20), token(90, 110), token(210, 220)], range)).toEqual([ + token(90, 110), + ]); + }); +}); diff --git a/src/features/editor/tests/tokenizer-worker-client.test.ts b/src/features/editor/tests/tokenizer-worker-client.test.ts new file mode 100644 index 000000000..ae2436acf --- /dev/null +++ b/src/features/editor/tests/tokenizer-worker-client.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + TokenizerRequestSupersededError, + TokenizerWorkerClient, +} from "../lib/wasm-parser/tokenizer-worker-client"; +import type { + TokenizerWorkerRequest, + TokenizerWorkerResponse, +} from "../lib/wasm-parser/worker-protocol"; + +class FakeWorker { + static instances: FakeWorker[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + messages: TokenizerWorkerRequest[] = []; + + constructor() { + FakeWorker.instances.push(this); + } + + postMessage(message: TokenizerWorkerRequest) { + this.messages.push(message); + } + + respond(index: number) { + const request = this.messages[index]; + if (!request) throw new Error(`Missing request ${index}`); + this.onmessage?.({ + data: { + id: request.id, + ok: true, + tokens: [], + normalizedText: request.type === "tokenize" ? request.content : undefined, + }, + } as unknown as MessageEvent); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); + FakeWorker.instances = []; +}); + +describe("TokenizerWorkerClient", () => { + it("keeps one request in flight per key and coalesces queued stale work", async () => { + vi.stubGlobal("Worker", FakeWorker); + const client = new TokenizerWorkerClient(); + const request = (content: string) => + client.tokenize({ + bufferId: "buffer", + latestKey: "surface", + content, + languageId: "typescript", + mode: "full", + }); + + const first = request("first"); + const second = request("second"); + const third = request("third"); + const firstResult = expect(first).rejects.toBeInstanceOf(TokenizerRequestSupersededError); + const secondResult = expect(second).rejects.toBeInstanceOf(TokenizerRequestSupersededError); + const worker = FakeWorker.instances[0]; + + expect(worker.messages).toHaveLength(1); + expect(worker.messages[0]).toEqual(expect.objectContaining({ content: "first" })); + worker.respond(0); + await firstResult; + await secondResult; + await vi.waitFor(() => expect(worker.messages).toHaveLength(2)); + expect(worker.messages[1]).toEqual(expect.objectContaining({ content: "third" })); + + worker.respond(1); + await expect(third).resolves.toEqual({ tokens: [], normalizedText: "third" }); + }); +}); diff --git a/src/features/editor/tests/undo-group-tracker.test.ts b/src/features/editor/tests/undo-group-tracker.test.ts index 0ef5dde84..707294e4d 100644 --- a/src/features/editor/tests/undo-group-tracker.test.ts +++ b/src/features/editor/tests/undo-group-tracker.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { EditorUndoGroupTracker } from "@/features/editor/history/undo-group-tracker"; +import type { StoredHistoryEntry } from "@/features/editor/types/history.types"; + +function snapshotContent(entry: StoredHistoryEntry | null): string | undefined { + return entry && "content" in entry ? entry.content : undefined; +} describe("editor undo group tracker", () => { it("flushes a typing group after only the cursor moved", () => { @@ -13,7 +18,7 @@ describe("editor undo group tracker", () => { const flushedEntry = tracker.flush("buffer-1", "asd"); - expect(flushedEntry?.content).toBe(""); + expect(snapshotContent(flushedEntry)).toBe(""); expect(flushedEntry?.cursorPosition).toEqual({ line: 0, column: 0, offset: 0 }); }); @@ -30,7 +35,7 @@ describe("editor undo group tracker", () => { expect(tracker.track("buffer-1", "asd\na", "asd\nas")).toEqual([]); expect(tracker.track("buffer-1", "asd\nas", "asd\nasd")).toEqual([]); - expect(tracker.flush("buffer-1", "asd\nasd")?.content).toBe("asd"); + expect(snapshotContent(tracker.flush("buffer-1", "asd\nasd"))).toBe("asd"); }); it("starts a new group when typing resumes at a different offset", () => { @@ -43,7 +48,7 @@ describe("editor undo group tracker", () => { expect(tracker.track("buffer-1", "asd", "xasd")).toEqual([ expect.objectContaining({ content: "" }), ]); - expect(tracker.flush("buffer-1", "xasd")?.content).toBe("asd"); + expect(snapshotContent(tracker.flush("buffer-1", "xasd"))).toBe("asd"); }); it("groups incremental Monaco changes without scanning for their offsets", () => { @@ -59,6 +64,6 @@ describe("editor undo group tracker", () => { contentChange: { rangeOffset: 1, rangeLength: 0, text: "b" }, }), ).toEqual([]); - expect(tracker.flush("buffer-1", "ab")?.content).toBe(""); + expect(snapshotContent(tracker.flush("buffer-1", "ab"))).toBe(""); }); }); diff --git a/src/features/editor/types/editor.types.ts b/src/features/editor/types/editor.types.ts index 4eb0d51e6..8d8bf0496 100644 --- a/src/features/editor/types/editor.types.ts +++ b/src/features/editor/types/editor.types.ts @@ -26,6 +26,40 @@ export interface EditorTextChange { endColumn?: number; } +export interface EditorModelTextChange extends EditorTextChange { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +export interface EditorDocumentChangeBatch { + sourceId: string; + modelSessionId: string; + modelVersionId: number; + changes: readonly EditorModelTextChange[]; + eol: "\n" | "\r\n"; + isEolChange: boolean; + isFlush: boolean; + isUndoing: boolean; + isRedoing: boolean; + /** Length of the model's text after the change, checked before a delta is accepted. */ + expectedContentLength?: number; + /** The whole document, sent instead of `changes` when a delta cannot be trusted. */ + fullContent?: string; +} + +export interface EditorDocumentChangeEvent extends EditorDocumentChangeBatch { + bufferId: string; + filePath: string; +} + +export interface EditorDocumentChangeResult { + accepted: boolean; + synchronized: boolean; + contentRevision: number; +} + export interface EditorContentChangeOptions { contentAlreadyApplied?: boolean; skipUndoGrouping?: boolean; diff --git a/src/features/editor/types/history.types.ts b/src/features/editor/types/history.types.ts index 3b15f4559..91aa8074f 100644 --- a/src/features/editor/types/history.types.ts +++ b/src/features/editor/types/history.types.ts @@ -7,9 +7,33 @@ export interface HistoryEntry { timestamp: number; } +export interface HistoryPatchChange { + rangeOffset: number; + beforeText: string; + afterText: string; +} + +export interface HistoryPatchBatch { + beforeLength: number; + afterLength: number; + changes: HistoryPatchChange[]; +} + +export interface PatchHistoryEntry { + kind: "patch"; + patches: HistoryPatchBatch[]; + beforeLength: number; + afterLength: number; + cursorPosition?: Position; + selection?: Range; + timestamp: number; +} + +export type StoredHistoryEntry = HistoryEntry | PatchHistoryEntry; + export interface HistoryState { - past: HistoryEntry[]; - future: HistoryEntry[]; + past: StoredHistoryEntry[]; + future: StoredHistoryEntry[]; maxHistorySize: number; } diff --git a/src/features/editor/utils/editor-text-changes.ts b/src/features/editor/utils/editor-text-changes.ts new file mode 100644 index 000000000..ca188e89a --- /dev/null +++ b/src/features/editor/utils/editor-text-changes.ts @@ -0,0 +1,44 @@ +import type { EditorModelTextChange } from "../types/editor.types"; + +function orderedChanges( + contentLength: number, + changes: readonly EditorModelTextChange[], +): EditorModelTextChange[] | null { + const ordered = [...changes].sort((left, right) => left.rangeOffset - right.rangeOffset); + let previousEnd = 0; + + for (const change of ordered) { + const end = change.rangeOffset + change.rangeLength; + if ( + !Number.isInteger(change.rangeOffset) || + !Number.isInteger(change.rangeLength) || + change.rangeOffset < previousEnd || + change.rangeOffset < 0 || + change.rangeLength < 0 || + end > contentLength + ) { + return null; + } + previousEnd = end; + } + + return ordered; +} + +export function applyEditorTextChanges( + content: string, + changes: readonly EditorModelTextChange[], +): string | null { + if (changes.length === 0) return content; + const ordered = orderedChanges(content.length, changes); + if (!ordered) return null; + + const pieces: string[] = []; + let sourceOffset = 0; + for (const change of ordered) { + pieces.push(content.slice(sourceOffset, change.rangeOffset), change.text); + sourceOffset = change.rangeOffset + change.rangeLength; + } + pieces.push(content.slice(sourceOffset)); + return pieces.join(""); +} diff --git a/src/features/editor/utils/large-file.ts b/src/features/editor/utils/large-file.ts index 746b06bc0..96294ded5 100644 --- a/src/features/editor/utils/large-file.ts +++ b/src/features/editor/utils/large-file.ts @@ -1,3 +1,5 @@ +import type { EditorModelTextChange } from "../types/editor.types"; + const LARGE_FILE_TOKENIZATION_SIZE_THRESHOLD = 20 * 1024 * 1024; const LARGE_FILE_TOKENIZATION_LINE_THRESHOLD = 300_000; const RESPONSIVE_LARGE_FILE_SIZE_THRESHOLD = 2 * 1024 * 1024; @@ -252,6 +254,52 @@ export function applyIncrementalLargeEditorModeInfo( }; } +export function applyEditorChangesToLargeEditorModeInfo( + previousContentLength: number, + previousInfo: LargeEditorModeInfo, + changes: readonly EditorModelTextChange[], +): LargeEditorModeInfo | null { + if (changes.length === 0) return previousInfo; + + let insertedNewlines = 0; + let removedNewlines = 0; + let contentLengthDelta = 0; + for (const change of changes) { + insertedNewlines += countNewlines(change.text); + removedNewlines += change.endLine - change.startLine; + contentLengthDelta += change.text.length - change.rangeLength; + } + + const lineCount = Math.max(1, previousInfo.lineCount + insertedNewlines - removedNewlines); + const nextContentLength = Math.max(0, previousContentLength + contentLengthDelta); + const largeContentMode = isTooLargeForEditorServices({ + contentLength: nextContentLength, + lineCount, + }); + + if (!largeContentMode) { + return { lineCount, largeContentMode: false }; + } + if (!previousInfo.lineOffsets) return null; + + let lineOffsets = previousInfo.lineOffsets; + const descendingChanges = [...changes].sort( + (left, right) => right.rangeOffset - left.rangeOffset, + ); + for (const change of descendingChanges) { + lineOffsets = updateLineOffsetsForEdit( + lineOffsets, + change.rangeOffset, + change.rangeOffset + change.rangeLength, + change.text, + change.text.length - change.rangeLength, + ); + } + if (lineOffsets.length !== lineCount) return null; + + return { lineCount, largeContentMode: true, lineOffsets }; +} + function buildLineOffsets(content: string): number[] { const lineOffsets = [0]; for (let index = 0; index < content.length; index++) { diff --git a/src/features/file-explorer/components/file-explorer-tree.tsx b/src/features/file-explorer/components/file-explorer-tree.tsx index 9bc3fb1b0..79588b38b 100644 --- a/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/src/features/file-explorer/components/file-explorer-tree.tsx @@ -35,10 +35,12 @@ import { } from "@/features/file-explorer/lib/file-tree-git-status"; import { collectGitIgnoreFileReferences, - createFileTreeGitIgnoreRules, + getCachedFileTreeGitIgnoreRules, + invalidateFileTreeGitIgnoreCache, isPathGitIgnoredByFileTreeRules, + readFileTreeGitIgnoreContents, + subscribeToFileTreeGitIgnoreCacheInvalidation, type FileTreeGitIgnoreRules, - type GitIgnoreFileContent, } from "@/features/file-explorer/lib/file-tree-gitignore"; import { fileOpenBenchmark } from "@/features/editor/utils/file-open-benchmark"; import { findFileInTree } from "@/features/file-system/controllers/file-tree-utils"; @@ -70,7 +72,6 @@ import { cn } from "@/utils/cn"; import { frontendTrace } from "@/utils/frontend-trace"; import { IS_MAC } from "@/utils/platform"; import { - getBaseName, getDirName, getRelativePath, joinPath, @@ -212,6 +213,7 @@ function FileExplorerTreeComponent({ const documentRef = useRef(document); const [gitIgnoreRules, setGitIgnoreRules] = useState(null); + const [gitIgnoreCacheVersion, setGitIgnoreCacheVersion] = useState(0); const workspaceGitStatus = useGitStore((state) => state.workspaceGitStatus); const currentWorkspaceRepoPath = useGitStore((state) => state.currentWorkspaceRepoPath); @@ -345,18 +347,19 @@ function FileExplorerTreeComponent({ .join("\n"); const gitIgnoreFileReferencesRef = useRef(collectedGitIgnoreFileReferences); gitIgnoreFileReferencesRef.current = collectedGitIgnoreFileReferences; - const [gitIgnoreContentVersion, setGitIgnoreContentVersion] = useState(0); + + useEffect( + () => + subscribeToFileTreeGitIgnoreCacheInvalidation((path) => { + if (!rootFolderPath || (path && !pathStartsWithRoot(path, rootFolderPath))) return; + setGitIgnoreCacheVersion((version) => version + 1); + }), + [rootFolderPath], + ); useEffect(() => { - const handleExternalChange = (event: Event) => { - const path = (event as CustomEvent<{ path?: string }>).detail?.path; - if (path && getBaseName(path) === ".gitignore") { - setGitIgnoreContentVersion((version) => version + 1); - } - }; - window.addEventListener("file-external-change", handleExternalChange); - return () => window.removeEventListener("file-external-change", handleExternalChange); - }, []); + invalidateFileTreeGitIgnoreCache(); + }, [rootFolderPath]); useEffect(() => { let cancelled = false; @@ -368,26 +371,10 @@ function FileExplorerTreeComponent({ return; } - const ignoreFiles = await Promise.all( - gitIgnoreFileReferences.map(async (file): Promise => { - try { - return { - ...file, - content: await readFile(file.path), - }; - } catch { - return null; - } - }), - ); + const ignoreFiles = await readFileTreeGitIgnoreContents(gitIgnoreFileReferences, readFile); if (!cancelled) { - setGitIgnoreRules( - createFileTreeGitIgnoreRules( - rootFolderPath, - ignoreFiles.filter((file): file is GitIgnoreFileContent => file !== null), - ), - ); + setGitIgnoreRules(getCachedFileTreeGitIgnoreRules(rootFolderPath, ignoreFiles)); } }; @@ -396,7 +383,7 @@ function FileExplorerTreeComponent({ return () => { cancelled = true; }; - }, [gitIgnoreContentVersion, gitIgnoreFileReferencesKey, rootFolderPath]); + }, [gitIgnoreCacheVersion, gitIgnoreFileReferencesKey, rootFolderPath]); const gitStatus = currentWorkspaceRepoPath && currentWorkspaceRepoPath === rootFolderPath diff --git a/src/features/file-explorer/lib/file-tree-gitignore.ts b/src/features/file-explorer/lib/file-tree-gitignore.ts index e7a14ffb3..097dac813 100644 --- a/src/features/file-explorer/lib/file-tree-gitignore.ts +++ b/src/features/file-explorer/lib/file-tree-gitignore.ts @@ -27,6 +27,23 @@ interface GitIgnoreRuleSet { matcher: IgnoreMatcher; } +type GitIgnoreReader = (path: string) => Promise; +type GitIgnoreCacheListener = (path: string | undefined) => void; +interface GitIgnoreContentCacheEntry { + promise: Promise; +} + +const referenceCache = new WeakMap(); +const contentCache = new Map(); +const matcherCache = new Map(); +const rulesCache = new Map< + string, + { ignoreFiles: readonly GitIgnoreFileContent[]; rules: FileTreeGitIgnoreRules | null } +>(); +const cacheListeners = new Set(); +const MAX_CACHED_GITIGNORE_FILES = 256; +const MAX_CACHED_GITIGNORE_ROOTS = 16; + export interface FileTreeGitIgnoreRules { rootFolderPath: string; ruleSets: GitIgnoreRuleSet[]; @@ -54,6 +71,45 @@ function addGitIgnoreContent(matcher: IgnoreMatcher, content: string): void { } } +function trimOldestCacheEntries(cache: Map, maxSize: number): void { + while (cache.size > maxSize) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) return; + cache.delete(oldestKey); + } +} + +function getCachedIgnoreMatcher(file: GitIgnoreFileContent): IgnoreMatcher { + const cacheKey = normalizePath(stripTrailingPathSeparators(file.path)); + const cached = matcherCache.get(cacheKey); + if (cached?.content === file.content) { + matcherCache.delete(cacheKey); + matcherCache.set(cacheKey, cached); + return cached.matcher; + } + + const matcher = ignore({ allowRelativePaths: true }); + addGitIgnoreContent(matcher, file.content); + matcherCache.set(cacheKey, { content: file.content, matcher }); + trimOldestCacheEntries(matcherCache, MAX_CACHED_GITIGNORE_FILES); + return matcher; +} + +function collectGitIgnoreReferencesForEntry(entry: FileEntry): GitIgnoreFileReference[] { + const cached = referenceCache.get(entry); + if (cached) return cached; + + const references: GitIgnoreFileReference[] = []; + if (entry.name === GITIGNORE_FILE_NAME && !entry.isDir) { + references.push({ path: entry.path, directoryPath: getDirName(entry.path) }); + } + for (const child of entry.children ?? []) { + references.push(...collectGitIgnoreReferencesForEntry(child)); + } + referenceCache.set(entry, references); + return references; +} + export function collectGitIgnoreFileReferences( files: FileEntry[], rootFolderPath: string | undefined, @@ -73,21 +129,87 @@ export function collectGitIgnoreFileReferences( addReference(joinPath(rootFolderPath, GITIGNORE_FILE_NAME)); - const walk = (entries: FileEntry[]) => { - for (const entry of entries) { - if (entry.name === GITIGNORE_FILE_NAME && !entry.isDir) { - addReference(entry.path); + for (const entry of files) { + for (const reference of collectGitIgnoreReferencesForEntry(entry)) { + addReference(reference.path); + } + } + + return [...references.values()].sort(compareIgnoreReferences); +} + +export async function readFileTreeGitIgnoreContents( + references: readonly GitIgnoreFileReference[], + read: GitIgnoreReader, +): Promise { + const ignoreFiles = await Promise.all( + references.map((reference) => { + const cacheKey = normalizePath(stripTrailingPathSeparators(reference.path)); + let entry = contentCache.get(cacheKey); + if (!entry) { + const newEntry: GitIgnoreContentCacheEntry = { promise: Promise.resolve(null) }; + newEntry.promise = read(reference.path) + .then((content) => + contentCache.get(cacheKey) === newEntry ? { ...reference, content } : null, + ) + .catch(() => null); + entry = newEntry; + contentCache.set(cacheKey, newEntry); + trimOldestCacheEntries(contentCache, MAX_CACHED_GITIGNORE_FILES); + } else { + contentCache.delete(cacheKey); + contentCache.set(cacheKey, entry); } + return entry.promise; + }), + ); + + return ignoreFiles.filter((file): file is GitIgnoreFileContent => file !== null); +} - if (entry.children) { - walk(entry.children); +export function invalidateFileTreeGitIgnoreCache(path?: string): void { + if (path) { + const cacheKey = normalizePath(stripTrailingPathSeparators(path)); + const pathParts = cacheKey.split("/"); + if (pathParts[pathParts.length - 1] !== GITIGNORE_FILE_NAME) return; + contentCache.delete(cacheKey); + matcherCache.delete(cacheKey); + for (const [rootPath, cached] of rulesCache) { + if (cached.ignoreFiles.some((file) => normalizePath(file.path) === cacheKey)) { + rulesCache.delete(rootPath); } } - }; + } else { + contentCache.clear(); + matcherCache.clear(); + rulesCache.clear(); + } - walk(files); + for (const listener of cacheListeners) { + listener(path); + } +} - return [...references.values()].sort(compareIgnoreReferences); +export function subscribeToFileTreeGitIgnoreCacheInvalidation( + listener: GitIgnoreCacheListener, +): () => void { + cacheListeners.add(listener); + return () => cacheListeners.delete(listener); +} + +function hasSameIgnoreFiles( + left: readonly GitIgnoreFileContent[], + right: readonly GitIgnoreFileContent[], +): boolean { + return ( + left.length === right.length && + left.every( + (file, index) => + file.path === right[index]?.path && + file.directoryPath === right[index]?.directoryPath && + file.content === right[index]?.content, + ) + ); } export function createFileTreeGitIgnoreRules( @@ -99,15 +221,10 @@ export function createFileTreeGitIgnoreRules( const ruleSets = ignoreFiles .filter((file) => pathStartsWithRoot(file.directoryPath, rootFolderPath)) .sort(compareIgnoreReferences) - .map((file) => { - const matcher = ignore({ allowRelativePaths: true }); - addGitIgnoreContent(matcher, file.content); - - return { - directoryPath: file.directoryPath, - matcher, - }; - }); + .map((file) => ({ + directoryPath: file.directoryPath, + matcher: getCachedIgnoreMatcher(file), + })); if (ruleSets.length === 0) return null; @@ -117,6 +234,24 @@ export function createFileTreeGitIgnoreRules( }; } +export function getCachedFileTreeGitIgnoreRules( + rootFolderPath: string | undefined, + ignoreFiles: GitIgnoreFileContent[], +): FileTreeGitIgnoreRules | null { + if (!rootFolderPath) return null; + + const cacheKey = normalizePath(stripTrailingPathSeparators(rootFolderPath)); + const cached = rulesCache.get(cacheKey); + if (cached && hasSameIgnoreFiles(cached.ignoreFiles, ignoreFiles)) { + return cached.rules; + } + + const rules = createFileTreeGitIgnoreRules(rootFolderPath, ignoreFiles); + rulesCache.set(cacheKey, { ignoreFiles: [...ignoreFiles], rules }); + trimOldestCacheEntries(rulesCache, MAX_CACHED_GITIGNORE_ROOTS); + return rules; +} + function toMatcherPath(fullPath: string, directoryPath: string, isDir: boolean): string | null { if (!pathStartsWithRoot(fullPath, directoryPath)) return null; diff --git a/src/features/file-explorer/tests/file-tree-gitignore.test.ts b/src/features/file-explorer/tests/file-tree-gitignore.test.ts index 59d6e8d22..fbb787084 100644 --- a/src/features/file-explorer/tests/file-tree-gitignore.test.ts +++ b/src/features/file-explorer/tests/file-tree-gitignore.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { FileEntry } from "@/features/file-system/types/app.types"; import { collectGitIgnoreFileReferences, createFileTreeGitIgnoreRules, + getCachedFileTreeGitIgnoreRules, + invalidateFileTreeGitIgnoreCache, isPathGitIgnoredByFileTreeRules, + readFileTreeGitIgnoreContents, } from "../lib/file-tree-gitignore"; const dir = (name: string, path: string, children?: FileEntry[]): FileEntry => ({ @@ -20,6 +23,8 @@ const file = (name: string, path: string): FileEntry => ({ }); describe("file tree gitignore rules", () => { + beforeEach(() => invalidateFileTreeGitIgnoreCache()); + it("collects root and nested .gitignore files from the loaded tree", () => { const references = collectGitIgnoreFileReferences( [ @@ -163,4 +168,63 @@ describe("file tree gitignore rules", () => { expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/.git", true)).toBe(false); expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/file.txt", false)).toBe(true); }); + + it("reuses file contents and parsed rules until the file is invalidated", async () => { + const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; + const read = vi.fn(async () => "dist/\n"); + + const firstContents = await readFileTreeGitIgnoreContents(references, read); + const secondContents = await readFileTreeGitIgnoreContents(references, read); + const firstRules = getCachedFileTreeGitIgnoreRules("/repo", firstContents); + const secondRules = getCachedFileTreeGitIgnoreRules("/repo", secondContents); + + expect(read).toHaveBeenCalledOnce(); + expect(secondRules).toBe(firstRules); + + invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); + const refreshedContents = await readFileTreeGitIgnoreContents(references, read); + const refreshedRules = getCachedFileTreeGitIgnoreRules("/repo", refreshedContents); + + expect(read).toHaveBeenCalledTimes(2); + expect(refreshedRules).not.toBe(firstRules); + }); + + it("does not let an invalidated pending read repopulate the cache", async () => { + const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; + let resolveFirstRead: ((content: string) => void) | undefined; + const read = vi + .fn<(_: string) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRead = resolve; + }), + ) + .mockResolvedValueOnce("fresh/\n"); + + const staleRead = readFileTreeGitIgnoreContents(references, read); + invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); + resolveFirstRead?.("stale/\n"); + expect(await staleRead).toEqual([]); + + const freshRead = await readFileTreeGitIgnoreContents(references, read); + expect(freshRead.map((file) => file.content)).toEqual(["fresh/\n"]); + expect(read).toHaveBeenCalledTimes(2); + }); + + it("retries a missing root ignore file after create invalidation", async () => { + const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; + const read = vi + .fn<(_: string) => Promise>() + .mockRejectedValueOnce(new Error("missing")) + .mockResolvedValueOnce("created/\n"); + + expect(await readFileTreeGitIgnoreContents(references, read)).toEqual([]); + expect(await readFileTreeGitIgnoreContents(references, read)).toEqual([]); + expect(read).toHaveBeenCalledOnce(); + + invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); + expect(await readFileTreeGitIgnoreContents(references, read)).toHaveLength(1); + expect(read).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/features/file-system/controllers/platform.ts b/src/features/file-system/controllers/platform.ts index e504ddc7c..4308b6c00 100644 --- a/src/features/file-system/controllers/platform.ts +++ b/src/features/file-system/controllers/platform.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { homeDir } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/plugin-dialog"; import { useLinuxFolderPickerStore } from "@/features/file-system/stores/linux-folder-picker.store"; +import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/lib/file-tree-gitignore"; import { parseWslPath } from "@/features/wsl/utils/wsl-path"; import { IS_LINUX } from "@/utils/platform"; import { @@ -72,6 +73,7 @@ export async function writeFile(path: string, content: string): Promise { filePath: wslInfo.linuxPath, content, }); + invalidateFileTreeGitIgnoreCache(path); return; } @@ -82,6 +84,7 @@ export async function writeFile(path: string, content: string): Promise { // Fallback to writing to app data directory await writeTextFile(path, content, { baseDir: BaseDirectory.AppData }); } + invalidateFileTreeGitIgnoreCache(path); } /** diff --git a/src/features/file-system/services/file-watcher-listener.ts b/src/features/file-system/services/file-watcher-listener.ts index 3b38c2ff0..cb25fbcd9 100644 --- a/src/features/file-system/services/file-watcher-listener.ts +++ b/src/features/file-system/services/file-watcher-listener.ts @@ -2,6 +2,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getBufferByPath } from "@/features/editor/utils/buffer-index"; import { emitGitChanged } from "@/features/git/events/git-events"; +import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/lib/file-tree-gitignore"; import { showToast } from "@/features/layout/contexts/toast-context"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { readFileContent } from "../controllers/file-operations"; @@ -95,6 +96,7 @@ async function syncOpenBuffer( } export async function handleFileChange({ path, event_type, agent_write_id }: FileChangeEvent) { + invalidateFileTreeGitIgnoreCache(path); const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); window.dispatchEvent( diff --git a/src/features/file-system/services/subtree-preload-queue.ts b/src/features/file-system/services/subtree-preload-queue.ts new file mode 100644 index 000000000..3dc4ac758 --- /dev/null +++ b/src/features/file-system/services/subtree-preload-queue.ts @@ -0,0 +1,24 @@ +export interface SubtreePreloadQueueItem { + path: string; + depth: number; +} + +export function takeSubtreePreloadBatch( + queue: SubtreePreloadQueueItem[], + visited: Set, + maxDepth: number, + remainingBudget: number, + batchSize = 8, +): SubtreePreloadQueueItem[] { + const batch: SubtreePreloadQueueItem[] = []; + const limit = Math.min(batchSize, Math.max(0, remainingBudget)); + + while (queue.length > 0 && batch.length < limit) { + const item = queue.shift(); + if (!item || visited.has(item.path) || item.depth >= maxDepth) continue; + visited.add(item.path); + batch.push(item); + } + + return batch; +} diff --git a/src/features/file-system/stores/file-system.store.ts b/src/features/file-system/stores/file-system.store.ts index 494aa5124..4b481fdaa 100644 --- a/src/features/file-system/stores/file-system.store.ts +++ b/src/features/file-system/stores/file-system.store.ts @@ -13,6 +13,7 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getBufferByPath } from "@/features/editor/utils/buffer-index"; import { fileOpenBenchmark } from "@/features/editor/utils/file-open-benchmark"; import { getLineSlice } from "@/features/editor/utils/large-file"; +import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/lib/file-tree-gitignore"; import { getAncestorDirectoryPaths } from "@/features/file-explorer/utils/file-explorer-tree-utils"; import { useFileTreeStore } from "@/features/file-explorer/stores/file-explorer-tree.store"; import { getGitStatus } from "@/features/git/api/git-status-api"; @@ -108,6 +109,10 @@ import { } from "../services/workspace-initialization-router"; import { resetWorkspaceResources } from "../services/workspace-reset"; import { readWorkspaceDirectoryEntries } from "../services/workspace-resource-provider"; +import { + takeSubtreePreloadBatch, + type SubtreePreloadQueueItem, +} from "../services/subtree-preload-queue"; import { getSymlinkInfo, openFolder, readDirectory } from "../controllers/platform"; import { useRecentFoldersStore } from "../stores/recent-folders.store"; import { useRecentFilesStore } from "../stores/recent-files.store"; @@ -187,6 +192,30 @@ const wrapWithRootFolder = ( ]; }; +const updateDirectoryChildrenBatch = ( + files: FileEntry[], + childrenByPath: ReadonlyMap, +): FileEntry[] => { + let changed = false; + const updatedFiles = files.map((file) => { + const children = childrenByPath.get(file.path); + if (children && file.isDir && (!file.children || file.children.length === 0)) { + changed = true; + return { ...file, children }; + } + + if (!file.children) return file; + + const updatedChildren = updateDirectoryChildrenBatch(file.children, childrenByPath); + if (updatedChildren === file.children) return file; + + changed = true; + return { ...file, children: updatedChildren }; + }); + + return changed ? updatedFiles : files; +}; + const getWorkspaceFolderPaths = (get: FileSystemGet) => normalizeWorkspaceFolders(get().rootFolderPath, get().workspaceFolders).map( (folder) => folder.path, @@ -600,6 +629,7 @@ const scheduleInactiveWorkspacePrewarm = () => { const createFileSystemStore = (workspaceId: string): StoreApi => { let latestFileOpenRequestId = 0; let latestTreeRevealRequestId = 0; + let latestSubtreePreloadRequestId = 0; let pendingSessionBuffers: BufferSession[] = []; let resumePendingSessionRestore: (() => void) | null = null; let deferredAiSession: ReturnType | undefined; @@ -1638,12 +1668,11 @@ const createFileSystemStore = (workspaceId: string): StoreApi { + const requestId = ++latestSubtreePreloadRequestId; + const workspaceRootPath = get().rootFolderPath; + let expectedFilesVersion = get().filesVersion; const visited = new Set(); - type QueueItem = { - path: string; - depth: number; - }; - const q: QueueItem[] = []; + const q: SubtreePreloadQueueItem[] = []; q.push({ path: rootPath, @@ -1652,56 +1681,71 @@ const createFileSystemStore = (workspaceId: string): StoreApi { - if (visited.has(item.path) || item.depth >= maxDepth) return; - visited.add(item.path); - processed++; + const node = findFileInTree(treeSnapshot, item.path); + if (!node || !node.isDir) return null; + if (node.children && node.children.length > 0) { + return { item, children: node.children, fetched: false }; + } try { - // Skip if children already present - const node = findFileInTree(get().files, item.path); - if (!node || !node.isDir) return; - if (node.children && node.children.length > 0) { - // Still enqueue subdirs to continue traversal - node.children - ?.filter((c) => c.isDir) - .forEach((c) => - q.push({ - path: c.path, - depth: item.depth + 1, - }), - ); - return; - } - const children = await readWorkspaceDirectoryEntries( item.path, - get().rootFolderPath ?? item.path, + workspaceRootPath ?? item.path, ); - - set((state) => { - state.files = updateFileInTree(state.files, item.path, (it) => ({ - ...it, - children, - })); - state.filesVersion++; - }); - - // Enqueue subdirs - children - .filter((c) => c.isDir) - .forEach((c) => - q.push({ - path: c.path, - depth: item.depth + 1, - }), - ); - } catch {} + return { item, children, fetched: true }; + } catch { + return null; + } }), ); + if ( + requestId !== latestSubtreePreloadRequestId || + get().rootFolderPath !== workspaceRootPath || + get().filesVersion !== expectedFilesVersion + ) { + return; + } + + const fetchedChildren = new Map(); + for (const result of results) { + if (!result) continue; + if (result.fetched) { + fetchedChildren.set(result.item.path, result.children); + } + for (const child of result.children) { + if (child.isDir) { + q.push({ path: child.path, depth: result.item.depth + 1 }); + } + } + } + + if (fetchedChildren.size > 0) { + set((state) => { + if (state.rootFolderPath !== workspaceRootPath) return; + const updatedFiles = updateDirectoryChildrenBatch(state.files, fetchedChildren); + if (updatedFiles === state.files) return; + state.files = updatedFiles; + state.filesVersion++; + }); + expectedFilesVersion = get().filesVersion; + } + // Yield to UI await new Promise((r) => setTimeout(r, 0)); } @@ -1902,6 +1946,9 @@ const createFileSystemStore = (workspaceId: string): StoreApi { @@ -1916,6 +1963,12 @@ const createFileSystemStore = (workspaceId: string): StoreApi { const entry = findFileInTree(get().files, path); await getWorkspaceEntryMutationProvider(path).deletePath(path, !!entry?.isDir); + invalidateFileTreeGitIgnoreCache(entry?.isDir ? undefined : path); const { buffers, actions } = useBufferStore.getStore(workspaceId).getState(); buffers @@ -2333,6 +2388,13 @@ const createFileSystemStore = (workspaceId: string): StoreApi { state.files = updateFileInTree(state.files, path, (item) => ({ diff --git a/src/features/file-system/tests/subtree-preload-queue.test.ts b/src/features/file-system/tests/subtree-preload-queue.test.ts new file mode 100644 index 000000000..e7ca2a293 --- /dev/null +++ b/src/features/file-system/tests/subtree-preload-queue.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; +import { takeSubtreePreloadBatch } from "../services/subtree-preload-queue"; + +describe("takeSubtreePreloadBatch", () => { + it("never reserves more directories than the remaining budget", () => { + const queue = Array.from({ length: 12 }, (_, index) => ({ + path: `/repo/dir-${index}`, + depth: 1, + })); + + const batch = takeSubtreePreloadBatch(queue, new Set(), 2, 3); + + expect(batch.map((item) => item.path)).toEqual(["/repo/dir-0", "/repo/dir-1", "/repo/dir-2"]); + expect(queue).toHaveLength(9); + }); + + it("skips duplicate and out-of-depth work without spending the budget", () => { + const queue = [ + { path: "/repo/already", depth: 1 }, + { path: "/repo/too-deep", depth: 2 }, + { path: "/repo/next", depth: 1 }, + ]; + const visited = new Set(["/repo/already"]); + + expect(takeSubtreePreloadBatch(queue, visited, 2, 1)).toEqual([ + { path: "/repo/next", depth: 1 }, + ]); + expect(visited).toEqual(new Set(["/repo/already", "/repo/next"])); + }); +}); diff --git a/src/features/git/hooks/use-git-diff-highlight.ts b/src/features/git/hooks/use-git-diff-highlight.ts index 8cf83281e..aefe99d57 100644 --- a/src/features/git/hooks/use-git-diff-highlight.ts +++ b/src/features/git/hooks/use-git-diff-highlight.ts @@ -4,7 +4,7 @@ import { fetchHighlightQuery, getDefaultParserWasmUrl, } from "@/features/editor/lib/wasm-parser/extension-assets"; -import { tokenizeByLine } from "@/features/editor/lib/wasm-parser/tokenizer"; +import { tokenizerWorkerClient } from "@/features/editor/lib/wasm-parser/tokenizer-worker-client"; import type { HighlightToken } from "@/features/editor/types/wasm-parser/wasm-parser.types"; import { buildLineOffsetMap } from "@/features/editor/utils/html"; import { getLanguageIdFromPath } from "@/features/editor/utils/language-id"; @@ -72,6 +72,17 @@ function mapTokensToDiffLines( return result; } +function groupTokensByLine(tokens: HighlightToken[]): Map { + const result = new Map(); + for (const token of tokens) { + const line = token.startPosition.row; + const lineTokens = result.get(line) ?? []; + lineTokens.push(token); + result.set(line, lineTokens); + } + return result; +} + function findLineIndexForOffset(lineOffsets: number[], offset: number): number { let low = 0; let high = Math.max(0, lineOffsets.length - 1); @@ -159,6 +170,7 @@ interface DiffTokenState { interface DiffHighlightInput { key: string; + filePath: string; languageId: string | null; oldContent: ReconstructedContent; newContent: ReconstructedContent; @@ -186,9 +198,13 @@ export function createDiffHighlightKey(lines: GitDiffLine[], filePath: string) { return `${filePath}:${lines.length}:${hash.toString(16)}`; } -function createDiffHighlightInput(lines: GitDiffLine[], filePath: string): DiffHighlightInput { +export function createDiffHighlightInput( + lines: GitDiffLine[], + filePath: string, +): DiffHighlightInput { return { key: createDiffHighlightKey(lines, filePath), + filePath, languageId: getLanguageId(filePath), oldContent: reconstructContent(lines, "old"), newContent: reconstructContent(lines, "new"), @@ -196,6 +212,47 @@ function createDiffHighlightInput(lines: GitDiffLine[], filePath: string): DiffH }; } +export async function tokenizeDiffContents({ + input, + wasmPath, + highlightQuery, +}: { + input: DiffHighlightInput; + wasmPath: string; + highlightQuery?: string; +}): Promise> { + const { filePath, languageId, oldContent, newContent, fallbackTokenMap } = input; + if (!languageId) return fallbackTokenMap; + + const tokenizeVersion = async (version: "old" | "new", content: ReconstructedContent) => { + if (!content.content) return new Map(); + const result = await tokenizerWorkerClient.tokenize({ + bufferId: `git-diff:${filePath}:${version}`, + latestKey: `git-diff:${filePath}:${version}`, + content: content.content, + languageId, + wasmPath, + highlightQuery, + mode: "full", + }); + return groupTokensByLine(result.tokens); + }; + + const [oldTokensByLine, newTokensByLine] = await Promise.all([ + tokenizeVersion("old", oldContent), + tokenizeVersion("new", newContent), + ]); + const merged = new Map(); + for (const [index, tokens] of mapTokensToDiffLines(oldTokensByLine, oldContent.lineMapping)) { + merged.set(index, tokens); + } + for (const [index, tokens] of mapTokensToDiffLines(newTokensByLine, newContent.lineMapping)) { + merged.set(index, tokens); + } + + return merged.size > 0 ? merged : fallbackTokenMap; +} + export function useDiffHighlighting( lines: GitDiffLine[], filePath: string, @@ -207,7 +264,7 @@ export function useDiffHighlighting( }); useEffect(() => { - const { key, languageId, oldContent, newContent, fallbackTokenMap } = input; + const { key, languageId, fallbackTokenMap } = input; if (!languageId) { setTokenState({ key, tokenMap: new Map() }); return; @@ -241,34 +298,13 @@ export function useDiffHighlighting( } } - const config = { languageId: lang, wasmPath, highlightQuery }; - - const [oldTokensByLine, newTokensByLine] = await Promise.all([ - oldContent.content - ? tokenizeByLine(oldContent.content, lang, config) - : Promise.resolve(new Map()), - newContent.content - ? tokenizeByLine(newContent.content, lang, config) - : Promise.resolve(new Map()), - ]); + const tokenMap = await tokenizeDiffContents({ input, wasmPath, highlightQuery }); if (cancelled) return; - const oldTokenMap = mapTokensToDiffLines(oldTokensByLine, oldContent.lineMapping); - const newTokenMap = mapTokensToDiffLines(newTokensByLine, newContent.lineMapping); - - const merged = new Map(); - - for (const [index, tokens] of oldTokenMap) { - merged.set(index, tokens); - } - for (const [index, tokens] of newTokenMap) { - merged.set(index, tokens); - } - setTokenState({ key, - tokenMap: merged.size > 0 ? merged : fallbackTokenMap, + tokenMap, }); } catch { if (cancelled) return; diff --git a/src/features/git/tests/git-diff-highlight.test.ts b/src/features/git/tests/git-diff-highlight.test.ts index 99cee97b4..fa964f052 100644 --- a/src/features/git/tests/git-diff-highlight.test.ts +++ b/src/features/git/tests/git-diff-highlight.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, test } from "vite-plus/test"; +import { describe, expect, test, vi } from "vite-plus/test"; + +const { tokenize } = vi.hoisted(() => ({ tokenize: vi.fn() })); + +vi.mock("@/features/editor/lib/wasm-parser/tokenizer-worker-client", () => ({ + tokenizerWorkerClient: { tokenize }, +})); import { createDiffHighlightKey, + createDiffHighlightInput, createLineBasedDiffTokenMap, + tokenizeDiffContents, } from "../hooks/use-git-diff-highlight"; import type { GitDiffLine } from "../types/git.types"; @@ -60,4 +68,54 @@ describe("git diff highlighting", () => { createDiffHighlightKey(lines, "src/example.ts"), ); }); + + test("tokenizes both reconstructed versions in the worker and maps their lines", async () => { + const lines: GitDiffLine[] = [ + { line_type: "context", content: "const value = 1;", old_line_number: 1, new_line_number: 1 }, + { line_type: "removed", content: "return value;", old_line_number: 2 }, + { line_type: "added", content: "return value + 1;", new_line_number: 2 }, + ]; + tokenize + .mockResolvedValueOnce({ + normalizedText: "", + tokens: [ + { + type: "token-old", + startIndex: 17, + endIndex: 23, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 1, column: 6 }, + }, + ], + }) + .mockResolvedValueOnce({ + normalizedText: "", + tokens: [ + { + type: "token-new", + startIndex: 17, + endIndex: 23, + startPosition: { row: 1, column: 0 }, + endPosition: { row: 1, column: 6 }, + }, + ], + }); + + const tokenMap = await tokenizeDiffContents({ + input: createDiffHighlightInput(lines, "src/example.ts"), + wasmPath: "/typescript.wasm", + highlightQuery: "(identifier) @variable", + }); + + expect(tokenMap.get(1)?.[0]?.type).toBe("token-old"); + expect(tokenMap.get(2)?.[0]?.type).toBe("token-new"); + expect(tokenize).toHaveBeenCalledTimes(2); + expect(tokenize).toHaveBeenCalledWith( + expect.objectContaining({ + bufferId: "git-diff:src/example.ts:old", + latestKey: "git-diff:src/example.ts:old", + mode: "full", + }), + ); + }); }); diff --git a/src/features/panes/types/pane-content.types.ts b/src/features/panes/types/pane-content.types.ts index 11ff0fcfe..8386cd68f 100644 --- a/src/features/panes/types/pane-content.types.ts +++ b/src/features/panes/types/pane-content.types.ts @@ -66,6 +66,7 @@ interface PaneContentBase { export interface EditorContent extends PaneContentBase { type: "editor"; content: string; + contentRevision?: number; savedContent: string; isDirty: boolean; isVirtual: boolean; diff --git a/src/features/terminal/hooks/use-terminal-connection.ts b/src/features/terminal/hooks/use-terminal-connection.ts index e3b7bf00e..ec1de47ea 100644 --- a/src/features/terminal/hooks/use-terminal-connection.ts +++ b/src/features/terminal/hooks/use-terminal-connection.ts @@ -8,6 +8,7 @@ import type { TerminalInput, TerminalSize } from "../types/terminal.types"; import type { TerminalTheme } from "./use-terminal-theme"; import { parseOsc7Directory } from "../utils/terminal-osc"; import { normalizeTerminalTitle } from "../utils/terminal-title"; +import { createTerminalOutputBuffer } from "../utils/terminal-output-buffer"; import { getTerminalOutputFlowAction, getTerminalSize, @@ -57,7 +58,6 @@ export function useTerminalConnection({ const lastExitInfoRef = useRef<{ exitCode?: number | null; signal?: string | null } | null>(null); const hadTerminalErrorRef = useRef(false); const lastSizeRef = useRef(null); - const queuedOutputBytesRef = useRef(0); const outputPausedRef = useRef(false); const writeInput = useCallback( @@ -135,7 +135,6 @@ export function useTerminalConnection({ lastExitInfoRef.current = null; hadTerminalErrorRef.current = false; lastSizeRef.current = null; - queuedOutputBytesRef.current = 0; outputPausedRef.current = false; if (connectionId) updateSession(sessionId, { title: "" }); void flush(); @@ -171,36 +170,30 @@ export function useTerminalConnection({ applyTerminalTheme(getTerminalTheme()); }); + const outputBuffer = createTerminalOutputBuffer({ + write: (bytes, callback) => terminal.write(bytes, callback), + onQueuedBytesChange: (queuedBytes) => { + const action = getTerminalOutputFlowAction(queuedBytes, outputPausedRef.current); + if (action === "pause") setOutputPaused(true); + if (action === "resume") setOutputPaused(false); + }, + onWriteError: () => { + hadTerminalErrorRef.current = true; + }, + }); + const unsubscribeEvents = subscribeToTerminalEvents(connectionId, (event) => { if (event.event === "output") { - const bytes = event.data; - queuedOutputBytesRef.current += bytes.byteLength; - - if ( - getTerminalOutputFlowAction(queuedOutputBytesRef.current, outputPausedRef.current) === - "pause" - ) { - setOutputPaused(true); - } - - terminal.write(bytes, () => { - queuedOutputBytesRef.current = Math.max( - 0, - queuedOutputBytesRef.current - bytes.byteLength, - ); - if ( - getTerminalOutputFlowAction(queuedOutputBytesRef.current, outputPausedRef.current) === - "resume" - ) { - setOutputPaused(false); - } - }); + outputBuffer.enqueue(event.data); return; } if (event.event === "error") { hadTerminalErrorRef.current = true; - terminal.writeln(`\r\n\x1b[31mError: ${event.message}\x1b[0m`); + void outputBuffer.whenDrained().then(() => { + if (outputBuffer.isDisposed()) return; + terminal.writeln(`\r\n\x1b[31mError: ${event.message}\x1b[0m`); + }); return; } @@ -209,45 +202,49 @@ export function useTerminalConnection({ return; } - void closeTerminalConnection({ connectionId, remoteConnectionId }).catch(() => {}); - releaseTerminalEventChannel(connectionId); - window.dispatchEvent( - new CustomEvent(TERMINAL_PROCESS_EXIT_EVENT, { - detail: { - sessionId, - exitCode: hadTerminalErrorRef.current - ? null - : (lastExitInfoRef.current?.exitCode ?? null), - signal: lastExitInfoRef.current?.signal ?? null, - }, - }), - ); - - if (hadTerminalErrorRef.current) { - terminal.writeln("\x1b[90mOpen a new terminal tab or close this one manually.\x1b[0m"); - return; - } + void outputBuffer.whenDrained().then(() => { + void closeTerminalConnection({ connectionId, remoteConnectionId }).catch(() => {}); + releaseTerminalEventChannel(connectionId); + window.dispatchEvent( + new CustomEvent(TERMINAL_PROCESS_EXIT_EVENT, { + detail: { + sessionId, + exitCode: hadTerminalErrorRef.current + ? null + : (lastExitInfoRef.current?.exitCode ?? null), + signal: lastExitInfoRef.current?.signal ?? null, + }, + }), + ); - const exitCode = lastExitInfoRef.current?.exitCode; - const signal = lastExitInfoRef.current?.signal; - if (exitCode === 0 && signal == null) { - onTerminalExitRef.current?.(sessionId); - return; - } + const exitCode = lastExitInfoRef.current?.exitCode; + const signal = lastExitInfoRef.current?.signal; + const exitedCleanly = !hadTerminalErrorRef.current && exitCode === 0 && signal == null; + if (exitedCleanly) { + onTerminalExitRef.current?.(sessionId); + return; + } + // The view was torn down while output was still draining; there is no terminal left to + // explain the exit in. + if (outputBuffer.isDisposed()) return; - const details = - signal != null - ? `signal ${signal}` - : exitCode != null - ? `exit code ${exitCode}` - : "unknown status"; - terminal.writeln(`\r\n\x1b[33mTerminal process exited unexpectedly (${details}).\x1b[0m`); - terminal.writeln("\x1b[90mOpen a new terminal tab or close this one manually.\x1b[0m"); + if (!hadTerminalErrorRef.current) { + const details = + signal != null + ? `signal ${signal}` + : exitCode != null + ? `exit code ${exitCode}` + : "unknown status"; + terminal.writeln(`\r\n\x1b[33mTerminal process exited unexpectedly (${details}).\x1b[0m`); + } + terminal.writeln("\x1b[90mOpen a new terminal tab or close this one manually.\x1b[0m"); + }); }); sendTerminalSize(terminal); return () => { + outputBuffer.dispose(); void flush(); if (outputPausedRef.current) setOutputPaused(false); for (const disposable of disposables) disposable.dispose(); diff --git a/src/features/terminal/stores/terminal.store.ts b/src/features/terminal/stores/terminal.store.ts index b64d0ca12..2dd961531 100644 --- a/src/features/terminal/stores/terminal.store.ts +++ b/src/features/terminal/stores/terminal.store.ts @@ -34,8 +34,13 @@ const createTerminalStore = () => return; } set((state) => { + const currentSession = state.sessions.get(sessionId) || {}; + const changed = Object.entries(updates).some( + ([key, value]) => !Object.is(currentSession[key as keyof Terminal], value), + ); + if (!changed) return state; + const newSessions = new Map(state.sessions); - const currentSession = newSessions.get(sessionId) || {}; newSessions.set(sessionId, { ...currentSession, ...updates }); return { sessions: newSessions }; }); @@ -47,6 +52,7 @@ const createTerminalStore = () => removeSession: (sessionId: string) => { set((state) => { + if (!state.sessions.has(sessionId)) return state; const newSessions = new Map(state.sessions); newSessions.delete(sessionId); return { sessions: newSessions }; @@ -54,7 +60,7 @@ const createTerminalStore = () => }, setWidthMode: (mode: TerminalWidthMode) => { - set({ widthMode: mode }); + set((state) => (state.widthMode === mode ? state : { widthMode: mode })); }, }, })); diff --git a/src/features/terminal/tests/terminal-output-buffer.test.ts b/src/features/terminal/tests/terminal-output-buffer.test.ts new file mode 100644 index 000000000..5e33e8713 --- /dev/null +++ b/src/features/terminal/tests/terminal-output-buffer.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTerminalOutputBuffer } from "../utils/terminal-output-buffer"; + +describe("terminal output buffer", () => { + it("coalesces a burst while preserving every byte in order", () => { + const scheduled: Array<() => void> = []; + const writes: Array<{ data: Uint8Array; done: () => void }> = []; + const buffer = createTerminalOutputBuffer({ + schedule: (callback) => scheduled.push(callback), + write: (data, done) => writes.push({ data, done }), + }); + + for (let index = 0; index < 1000; index += 1) { + buffer.enqueue(Uint8Array.from({ length: 32 }, (_, offset) => (index + offset) & 0xff)); + } + expect(scheduled).toHaveLength(1); + scheduled[0]?.(); + + expect(writes).toHaveLength(1); + expect(writes[0]?.data.byteLength).toBe(32_000); + expect(Array.from(writes[0]?.data.slice(0, 35) ?? [])).toEqual([ + ...Array.from({ length: 32 }, (_, offset) => offset), + 1, + 2, + 3, + ]); + writes[0]?.done(); + expect(buffer.queuedBytes()).toBe(0); + }); + + it("limits each xterm write and waits for its callback before the next write", () => { + const writes: Array<{ data: Uint8Array; done: () => void }> = []; + const buffer = createTerminalOutputBuffer({ + maxBatchBytes: 64, + schedule: (callback) => callback(), + write: (data, done) => writes.push({ data, done }), + }); + + buffer.enqueue(Uint8Array.from({ length: 150 }, (_, index) => index)); + expect(writes.map(({ data }) => data.byteLength)).toEqual([64]); + writes[0]?.done(); + expect(writes.map(({ data }) => data.byteLength)).toEqual([64, 64]); + writes[1]?.done(); + expect(writes.map(({ data }) => data.byteLength)).toEqual([64, 64, 22]); + writes[2]?.done(); + expect(buffer.queuedBytes()).toBe(0); + }); + + it("resolves close draining after all output callbacks and recovers from write errors", async () => { + let done: (() => void) | undefined; + const buffer = createTerminalOutputBuffer({ + schedule: (callback) => callback(), + write: (_data, callback) => { + done = callback; + }, + }); + buffer.enqueue(new Uint8Array([1, 2, 3])); + const drained = vi.fn(); + void buffer.whenDrained().then(drained); + await Promise.resolve(); + expect(drained).not.toHaveBeenCalled(); + done?.(); + await buffer.whenDrained(); + expect(drained).toHaveBeenCalledOnce(); + + const onWriteError = vi.fn(); + const failed = createTerminalOutputBuffer({ + schedule: (callback) => callback(), + write: () => { + throw new Error("xterm disposed"); + }, + onWriteError, + }); + failed.enqueue(new Uint8Array([4])); + await failed.whenDrained(); + expect(onWriteError).toHaveBeenCalledOnce(); + }); + + it("releases close waiters when the terminal goes away mid-write", async () => { + const writes: Array<{ data: Uint8Array; done: () => void }> = []; + const buffer = createTerminalOutputBuffer({ + maxBatchBytes: 2, + schedule: (callback) => callback(), + write: (data, done) => writes.push({ data, done }), + }); + buffer.enqueue(new Uint8Array([1, 2, 3, 4, 5])); + const drained = vi.fn(); + void buffer.whenDrained().then(drained); + + buffer.dispose(); + await Promise.resolve(); + + expect(drained).toHaveBeenCalledOnce(); + expect(buffer.isDisposed()).toBe(true); + expect(buffer.queuedBytes()).toBe(0); + expect(writes.map(({ data }) => Array.from(data))).toEqual([ + [1, 2], + [3, 4, 5], + ]); + writes[0]?.done(); + buffer.enqueue(new Uint8Array([6])); + expect(writes).toHaveLength(2); + await expect(buffer.whenDrained()).resolves.toBeUndefined(); + }); + + it("drains many small chunks across batches in order", () => { + const writes: Array<{ data: Uint8Array; done: () => void }> = []; + const buffer = createTerminalOutputBuffer({ + maxBatchBytes: 1000, + schedule: (callback) => callback(), + write: (data, done) => writes.push({ data, done }), + }); + for (let index = 0; index < 5000; index += 1) { + buffer.enqueue(new Uint8Array([index & 0xff])); + } + let received: number[] = []; + for (let guard = 0; guard < 10_000 && writes.length > 0; guard += 1) { + const next = writes.shift(); + if (!next) break; + received = received.concat(Array.from(next.data)); + next.done(); + } + expect(received).toHaveLength(5000); + expect(received.every((value, index) => value === (index & 0xff))).toBe(true); + expect(buffer.queuedBytes()).toBe(0); + }); +}); diff --git a/src/features/terminal/tests/terminal-store.test.ts b/src/features/terminal/tests/terminal-store.test.ts new file mode 100644 index 000000000..7485a8379 --- /dev/null +++ b/src/features/terminal/tests/terminal-store.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { useTerminalStore } from "../stores/terminal.store"; + +describe("terminal store updates", () => { + it("does not notify subscribers or replace the session map for no-op metadata", () => { + const store = useTerminalStore.getStore("terminal-store-no-op-test"); + store.getState().actions.updateSession("terminal-1", { title: "shell" }); + const initialSessions = store.getState().sessions; + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + for (let index = 0; index < 1000; index += 1) { + store.getState().actions.updateSession("terminal-1", { title: "shell" }); + } + + expect(listener).not.toHaveBeenCalled(); + expect(store.getState().sessions).toBe(initialSessions); + unsubscribe(); + }); +}); diff --git a/src/features/terminal/utils/terminal-output-buffer.ts b/src/features/terminal/utils/terminal-output-buffer.ts new file mode 100644 index 000000000..c817169b8 --- /dev/null +++ b/src/features/terminal/utils/terminal-output-buffer.ts @@ -0,0 +1,153 @@ +const DEFAULT_MAX_BATCH_BYTES = 64 * 1024; + +interface TerminalOutputBufferOptions { + maxBatchBytes?: number; + onQueuedBytesChange?: (queuedBytes: number) => void; + onWriteError?: (error: unknown) => void; + schedule?: (callback: () => void) => void; + write: (data: Uint8Array, callback: () => void) => void; +} + +export interface TerminalOutputBuffer { + enqueue: (data: Uint8Array) => void; + flush: () => void; + queuedBytes: () => number; + whenDrained: () => Promise; + /** + * Hands every queued byte to the terminal at once and stops waiting for write callbacks. A + * disposed xterm never calls them back, so anything waiting on `whenDrained` would otherwise + * hang and keep the connection it was going to close. + */ + dispose: () => void; + isDisposed: () => boolean; +} + +export function createTerminalOutputBuffer({ + maxBatchBytes = DEFAULT_MAX_BATCH_BYTES, + onQueuedBytesChange, + onWriteError, + schedule = queueMicrotask, + write, +}: TerminalOutputBufferOptions): TerminalOutputBuffer { + const chunks: Uint8Array[] = []; + const drainedResolvers = new Set<() => void>(); + // Consumed chunks are skipped by index instead of shifted off, so a burst of many small chunks + // drains in linear time. + let headIndex = 0; + let firstChunkOffset = 0; + let queuedByteCount = 0; + let writeInProgress = false; + let flushScheduled = false; + let disposed = false; + + const hasQueuedChunks = () => headIndex < chunks.length; + const notifyQueuedBytes = () => onQueuedBytesChange?.(queuedByteCount); + const resolveDrained = () => { + if (!disposed && (queuedByteCount !== 0 || writeInProgress || hasQueuedChunks())) return; + for (const resolve of drainedResolvers) resolve(); + drainedResolvers.clear(); + }; + + const compactChunks = () => { + if (headIndex === chunks.length) { + chunks.length = 0; + headIndex = 0; + } else if (headIndex >= 1024 && headIndex * 2 >= chunks.length) { + chunks.splice(0, headIndex); + headIndex = 0; + } + }; + + const takeBatch = (limit: number): Uint8Array => { + let size = 0; + for (let index = headIndex; index < chunks.length && size < limit; index += 1) { + size += chunks[index].byteLength - (index === headIndex ? firstChunkOffset : 0); + } + size = Math.min(size, limit); + + const batch = new Uint8Array(size); + let written = 0; + while (written < size && hasQueuedChunks()) { + const chunk = chunks[headIndex]; + const length = Math.min(chunk.byteLength - firstChunkOffset, size - written); + batch.set(chunk.subarray(firstChunkOffset, firstChunkOffset + length), written); + written += length; + firstChunkOffset += length; + if (firstChunkOffset === chunk.byteLength) { + headIndex += 1; + firstChunkOffset = 0; + } + } + compactChunks(); + return batch; + }; + + const drain = () => { + flushScheduled = false; + if (disposed || writeInProgress || !hasQueuedChunks()) { + resolveDrained(); + return; + } + const batch = takeBatch(maxBatchBytes); + writeInProgress = true; + let completed = false; + const complete = () => { + if (completed || disposed) return; + completed = true; + writeInProgress = false; + queuedByteCount = Math.max(0, queuedByteCount - batch.byteLength); + notifyQueuedBytes(); + drain(); + }; + try { + write(batch, complete); + } catch (error) { + onWriteError?.(error); + complete(); + } + }; + + const scheduleFlush = () => { + if (disposed || flushScheduled || writeInProgress) return; + flushScheduled = true; + schedule(drain); + }; + + return { + enqueue: (data) => { + if (disposed || data.byteLength === 0) return; + chunks.push(data); + queuedByteCount += data.byteLength; + notifyQueuedBytes(); + scheduleFlush(); + }, + flush: drain, + queuedBytes: () => queuedByteCount, + whenDrained: () => { + if (disposed || (queuedByteCount === 0 && !writeInProgress && !hasQueuedChunks())) { + return Promise.resolve(); + } + return new Promise((resolve) => drainedResolvers.add(resolve)); + }, + dispose: () => { + if (disposed) return; + const remaining = hasQueuedChunks() ? takeBatch(Number.POSITIVE_INFINITY) : null; + disposed = true; + chunks.length = 0; + headIndex = 0; + firstChunkOffset = 0; + writeInProgress = false; + if (remaining && remaining.byteLength > 0) { + try { + write(remaining, () => {}); + } catch (error) { + onWriteError?.(error); + } + } + queuedByteCount = 0; + notifyQueuedBytes(); + resolveDrained(); + }, + isDisposed: () => disposed, + }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 78b607c7a..db8668816 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,6 +8,45 @@ declare module "monaco-editor/esm/vs/editor/common/services/resolverService.js" declare module "monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js" { export const StandaloneServices: { get(serviceId: unknown): unknown }; } +declare module "monaco-editor/esm/vs/editor/common/core/range.js" { + export class Range { + constructor( + startLineNumber: number, + startColumn: number, + endLineNumber: number, + endColumn: number, + ); + } +} +declare module "monaco-editor/esm/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.js" { + interface PieceTreeContentChange { + range: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + }; + rangeOffset: number; + rangeLength: number; + text: string; + } + interface PieceTreeTextBuffer { + getEOL(): string; + getLength(): number; + getLinesContent(): string[]; + applyEdits( + operations: { range: unknown; text: string }[], + recordTrimAutoWhitespace: boolean, + computeUndoEdits: boolean, + ): { changes: PieceTreeContentChange[] }; + } + export class PieceTreeTextBufferBuilder { + acceptChunk(chunk: string): void; + finish(normalizeEOL?: boolean): { + create(defaultEOL: number): { textBuffer: PieceTreeTextBuffer }; + }; + } +} interface SpeechRecognitionAlternative { transcript: string;