From f70d49eb0c53d7048f40ef51a8c357670b32a7c1 Mon Sep 17 00:00:00 2001 From: SnaetWarre Date: Sun, 20 Sep 2026 23:26:09 +0200 Subject: [PATCH 1/5] Stream editor, LSP, and terminal data flow for large files Route Monaco edits through document change batches so LSP sync, undo history, and the view layer share one incremental path instead of full re-parses on every keystroke. Track the sync state in a new Rust document session that maps Monaco offsets onto LSP versions and reuses pending notifications. Make the file tree filter, watcher preload queue, and terminal output buffer incremental so scrolling and typing stay responsive with large repos. Keep the view snapshot guard cheap with a revision plus content reference check. Cover the new paths with regression tests and baseline evidence. --- crates/lsp/src/client.rs | 32 + crates/lsp/src/document_sync.rs | 582 ++++++++++++++++++ crates/lsp/src/lib.rs | 2 + crates/lsp/src/manager.rs | 142 ++++- crates/remote/src/terminal.rs | 60 +- .../src/commands/development/lsp/commands.rs | 19 +- src-tauri/src/main.rs | 1 + .../components/messages/markdown-renderer.tsx | 11 +- .../editor/components/code-editor.tsx | 20 +- .../editor/components/monaco-editor.tsx | 135 ++-- .../editor/engines/monaco/content-sync.ts | 28 +- .../editor/engines/monaco/model-lifecycle.ts | 28 +- .../editor/history/history-patches.ts | 101 +++ .../editor/history/undo-group-tracker.ts | 103 +++- .../editor/hooks/use-lsp-integration.ts | 46 +- src/features/editor/hooks/use-tokenizer.ts | 2 + .../editor/lib/wasm-parser/tokenizer-range.ts | 24 + .../wasm-parser/tokenizer-worker-client.ts | 101 ++- .../lib/wasm-parser/tokenizer-worker.ts | 41 +- .../editor/lib/wasm-parser/worker-protocol.ts | 1 + src/features/editor/lsp/lsp-client.ts | 247 ++++++-- .../editor/markdown/code-highlight.ts | 83 ++- .../markdown/use-highlighted-markdown.ts | 7 +- .../editor/notebook/highlighted-code.tsx | 7 +- .../editor/services/editor-document-events.ts | 22 + .../editor/stores/buffer-content-factory.ts | 1 + .../editor/stores/buffer-history-tracking.ts | 30 +- src/features/editor/stores/buffer.store.ts | 137 ++++- .../editor/stores/editor-app.store.ts | 136 +++- src/features/editor/stores/history.store.ts | 119 +++- src/features/editor/stores/view.store.ts | 88 ++- .../tests/buffer-history-tracking.test.ts | 50 ++ .../tests/editor-document-changes.test.ts | 124 ++++ .../editor/tests/history-store.test.ts | 4 +- .../editor/tests/lsp-document-sync.test.ts | 181 ++++++ .../tests/markdown-code-highlight.test.ts | 40 +- .../editor/tests/monaco-content-sync.test.ts | 42 +- .../tests/monaco-model-lifecycle.test.ts | 20 +- .../editor/tests/tokenizer-range.test.ts | 33 + .../tests/tokenizer-worker-client.test.ts | 75 +++ .../editor/tests/undo-group-tracker.test.ts | 13 +- src/features/editor/types/editor.types.ts | 31 + src/features/editor/types/history.types.ts | 28 +- .../editor/utils/editor-text-changes.ts | 48 ++ src/features/editor/utils/large-file.ts | 48 ++ .../components/file-explorer-tree.tsx | 42 +- .../file-explorer/lib/file-tree-gitignore.ts | 171 ++++- .../lib/visible-file-tree-rows.ts | 26 +- .../tests/file-tree-gitignore.test.ts | 66 +- .../file-system/controllers/platform.ts | 3 + .../services/file-watcher-listener.ts | 2 + .../services/subtree-preload-queue.ts | 24 + .../file-system/stores/file-system.store.ts | 159 +++-- .../tests/subtree-preload-queue.test.ts | 34 + .../git/hooks/use-git-diff-highlight.ts | 88 ++- .../git/tests/git-diff-highlight.test.ts | 60 +- .../panes/types/pane-content.types.ts | 1 + .../terminal/hooks/use-terminal-connection.ts | 85 ++- .../terminal/stores/terminal.store.ts | 10 +- .../tests/terminal-output-buffer.test.ts | 78 +++ .../terminal/tests/terminal-store.test.ts | 20 + .../terminal/utils/terminal-output-buffer.ts | 113 ++++ 62 files changed, 3546 insertions(+), 529 deletions(-) create mode 100644 crates/lsp/src/document_sync.rs create mode 100644 src/features/editor/history/history-patches.ts create mode 100644 src/features/editor/lib/wasm-parser/tokenizer-range.ts create mode 100644 src/features/editor/services/editor-document-events.ts create mode 100644 src/features/editor/tests/editor-document-changes.test.ts create mode 100644 src/features/editor/tests/lsp-document-sync.test.ts create mode 100644 src/features/editor/tests/tokenizer-range.test.ts create mode 100644 src/features/editor/tests/tokenizer-worker-client.test.ts create mode 100644 src/features/editor/utils/editor-text-changes.ts create mode 100644 src/features/file-system/services/subtree-preload-queue.ts create mode 100644 src/features/file-system/tests/subtree-preload-queue.test.ts create mode 100644 src/features/terminal/tests/terminal-output-buffer.test.ts create mode 100644 src/features/terminal/tests/terminal-store.test.ts create mode 100644 src/features/terminal/utils/terminal-output-buffer.ts 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..ebbd0b956 --- /dev/null +++ b/crates/lsp/src/document_sync.rs @@ -0,0 +1,582 @@ +use anyhow::{Context, Result, bail}; +use lsp_types::{Position, Range, TextDocumentContentChangeEvent}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + 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, +} + +#[derive(Debug, Clone)] +struct DocumentSession { + content: String, + line_starts: Vec, + model_session_id: Option, + retired_model_sessions: HashSet, + model_version_id: i64, + lsp_version: i32, + pending_changes: Vec, + generation: u64, + sync_mode: SyncMode, + epoch: u64, + emission_lock: Arc>, +} + +pub(crate) struct PendingDocumentChanges { + pub version: i32, + pub changes: Vec, +} + +#[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: HashSet::new(), + model_version_id: 0, + lsp_version: 1, + pending_changes: Vec::new(), + 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]) + } + + 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 staged = session.clone(); + for batch in batches { + apply_batch(&mut staged, batch)?; + } + let result = ( + staged.epoch, + staged.generation, + staged.lsp_version.saturating_add(1), + ); + *session = staged; + Ok(result) + } + + #[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(&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(pending) = pending + && let Err(error) = emit(&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 !session + .pending_changes + .iter() + .any(|change| change.range.is_none()) + { + 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()) + } +} + +fn apply_batch(session: &mut DocumentSession, 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"); + } + + let mut next_content = session.content.clone(); + let mut next_line_starts = session.line_starts.clone(); + let mut next_pending = session.pending_changes.clone(); + if replacement { + next_content = batch + .full_content + .context("Full document content is required for replacement changes")?; + next_line_starts = collect_line_starts(&next_content); + next_pending.clear(); + next_pending.push(TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: next_content.clone(), + }); + } else { + 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 { + apply_change(&mut next_content, &mut next_line_starts, &change)?; + next_pending.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 !replacement { + 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.insert(previous_session); + } + session.model_version_id = batch.model_version_id; + } + session.content = next_content; + session.line_starts = next_line_starts; + session.pending_changes = next_pending; + session.generation = session.generation.wrapping_add(1); + Ok(()) +} + +fn take_pending(session: &mut DocumentSession) -> Option { + if session.pending_changes.is_empty() || session.sync_mode == SyncMode::None { + session.pending_changes.clear(); + return None; + } + + session.lsp_version = session.lsp_version.saturating_add(1); + let changes = if session.sync_mode == SyncMode::Incremental + && session + .pending_changes + .iter() + .all(|change| change.range.is_some()) + { + std::mem::take(&mut session.pending_changes) + } else { + session.pending_changes.clear(); + vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: session.content.clone(), + }] + }; + Some(PendingDocumentChanges { + version: session.lsp_version, + changes, + }) +} + +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_byte_length = end - start; + 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_byte_length 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(()) +} + +#[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"); + } +} 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..42d317edf 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,21 @@ pub struct LspManager { registry: LspRegistry, app_handle: AppHandle, settings: LspSettings, + document_sessions: DocumentSessions, +} + +fn send_document_changes( + client: &LspClient, + file_path: &str, + pending: &PendingDocumentChanges, +) -> Result<()> { + client.text_document_did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + uri: manager_support::text_document_identifier(file_path)?.uri, + version: pending.version, + }, + content_changes: pending.changes.clone(), + }) } impl LspManager { @@ -31,6 +47,7 @@ impl LspManager { registry: LspRegistry::new(), app_handle, settings: LspSettings::default(), + document_sessions: DocumentSessions::default(), } } @@ -350,11 +367,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 +1400,132 @@ 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(); + tokio::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 bf4e66751..e4ea9bd05 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -406,6 +406,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/ai/components/messages/markdown-renderer.tsx b/src/features/ai/components/messages/markdown-renderer.tsx index a541730c4..919201be3 100644 --- a/src/features/ai/components/messages/markdown-renderer.tsx +++ b/src/features/ai/components/messages/markdown-renderer.tsx @@ -6,7 +6,7 @@ import { WarningCircleIcon, } from "@/ui/icons"; import type React from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { getAcpAuthenticationCommand } from "@/features/ai/lib/acp-authentication"; import { AcpStreamHandler } from "@/features/ai/services/acp-stream-handler"; @@ -127,6 +127,7 @@ function CodeBlock({ languageHint: string; onApplyCode?: (code: string, language?: string) => void; }) { + const highlightRequestKey = `ai-code-highlight:${useId()}`; const explicitLanguage = languageHint ? normalizeCodeFenceLanguage(languageHint) : ""; const inferredLanguage = explicitLanguage || inferCodeLanguage(code); const languageLabel = explicitLanguage || (inferredLanguage !== "clike" ? inferredLanguage : ""); @@ -138,7 +139,11 @@ function CodeBlock({ setSegments(null); const loadHighlighting = async () => { - const nextSegments = await getCodeHighlightSegments(code, inferredLanguage); + const nextSegments = await getCodeHighlightSegments( + code, + inferredLanguage, + highlightRequestKey, + ); if (!cancelled) { setSegments(nextSegments); } @@ -149,7 +154,7 @@ function CodeBlock({ return () => { cancelled = true; }; - }, [code, inferredLanguage]); + }, [code, highlightRequestKey, inferredLanguage]); const renderedCode = useMemo(() => renderHighlightedCode(code, segments || []), [code, segments]); diff --git a/src/features/editor/components/code-editor.tsx b/src/features/editor/components/code-editor.tsx index 4c4ee32d3..06726fd5b 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"; @@ -159,7 +164,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); @@ -177,6 +182,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 showNotebookEditor = activeBuffer?.type === "editor" && filePath.toLowerCase().endsWith(".ipynb"); @@ -603,7 +616,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 2467d1b15..1eb99a08a 100644 --- a/src/features/editor/components/monaco-editor.tsx +++ b/src/features/editor/components/monaco-editor.tsx @@ -54,7 +54,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 { createEditorSelectionContext } from "../utils/editor-agent-context"; import { fileOpenBenchmark } from "../utils/file-open-benchmark"; @@ -62,10 +68,7 @@ import { getLanguageIdFromPath } from "../utils/language-id"; import { editorAPI } from "../extensions/api"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; -import { - consumeLocalContentSnapshot, - rememberLocalContentSnapshot, -} from "../engines/monaco/content-sync"; +import { isExternalModelUpdate, runWithExternalModelUpdate } from "../engines/monaco/content-sync"; import { clampMonacoHoverWidgets, mutationsContainMonacoHoverWidget, @@ -73,7 +76,10 @@ import { } from "../engines/monaco/hover-widgets"; import { toMonacoLanguageId } from "../engines/monaco/language"; import { ensureMonacoLanguageTokenizer } from "../engines/monaco/language-contributions"; -import { acquireMonacoModel } from "../engines/monaco/model-lifecycle"; +import { + acquireMonacoModel, + markMonacoModelContentRevision, +} from "../engines/monaco/model-lifecycle"; import { getEditorBottomScrollPadding } from "../engines/monaco/scroll-padding"; import { getMonacoScrollbarOptions } from "../engines/monaco/scrollbar-options"; import { @@ -95,6 +101,7 @@ registerMonacoCodeLensProvider(); const EMPTY_DIAGNOSTICS: Diagnostic[] = []; const INACTIVE_CURSOR_POSITION: Position = { line: 0, column: 0, offset: 0 }; +let nextEditorSourceId = 1; function createBreakpointHoverDecorations( hoveredLine: number | null, @@ -139,6 +146,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; @@ -162,6 +174,7 @@ export function MonacoEditor({ lineNumberStart, lineNumberMap, onContentChange, + onDocumentChange, onScrollOffsetChange, onModelPositionResolverChange, onMouseMove, @@ -175,9 +188,9 @@ 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 decorationsRef = useRef([]); const breakpointDecorationRef = useRef([]); const breakpointHoverDecorationRef = useRef([]); @@ -192,6 +205,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( @@ -199,6 +213,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); @@ -378,8 +393,9 @@ export function MonacoEditor({ useLayoutEffect(() => { latestContentChangeRef.current = onContentChange; + latestDocumentChangeRef.current = onDocumentChange; isActiveSurfaceRef.current = isActiveSurface; - }, [isActiveSurface, onContentChange]); + }, [isActiveSurface, onContentChange, onDocumentChange]); const syncSelectionAgentAction = useCallback(() => { const editor = editorRef.current; @@ -704,8 +720,13 @@ export function MonacoEditor({ }, ); - const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri); + const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri, contentRevision); const model = acquiredModel.model; + if (acquiredModel.contentRevision !== contentRevision) { + runWithExternalModelUpdate(model, () => model.setValue(content)); + markMonacoModelContentRevision(model, contentRevision); + } + modelSessionIdRef.current = acquiredModel.sessionId; const editor = monacoEditor.create(container, { model, automaticLayout: true, @@ -757,8 +778,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`); } @@ -1038,31 +1058,56 @@ 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, + const changes = event.changes.map((change) => ({ + 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, + })); + const batch: EditorDocumentChangeBatch = { + sourceId: sourceIdRef.current, + modelSessionId: modelSessionIdRef.current, + modelVersionId: event.versionId, + changes, + eol: event.eol === "\r\n" ? "\r\n" : "\n", + isEolChange: event.isEolChange, + isFlush: event.isFlush, + isUndoing: event.isUndoing, + isRedoing: event.isRedoing, + }; + const result = latestDocumentChangeRef.current?.( + batch, 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, ); + if (result?.synchronized) { + appliedContentRevisionRef.current = Math.max( + appliedContentRevisionRef.current, + result.contentRevision, + ); + markMonacoModelContentRevision(model, result.contentRevision); + } else if (result) { + const currentBuffer = getBufferById(useBufferStore.getState().buffers, activeBufferId); + if (currentBuffer?.type === "editor") { + runWithExternalModelUpdate(model, () => model.setValue(currentBuffer.content)); + appliedContentRevisionRef.current = currentBuffer.contentRevision ?? 0; + markMonacoModelContentRevision(model, appliedContentRevisionRef.current); + } + } else if (latestContentChangeRef.current) { + const nextContent = model.getValue(); + latestContentChangeRef.current( + nextContent, + undefined, + editorState.cursorPosition, + editorState.selection, + changes.length === 1 ? { contentChange: changes[0] } : undefined, + ); + } syncCursorAndSelection(); syncSelectionAgentActionRef.current(); }), @@ -1456,25 +1501,13 @@ 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; - } - - applyingExternalChangeRef.current = true; + if (contentRevision <= appliedContentRevisionRef.current) return; const selection = editor.getSelection(); - model.setValue(content); + runWithExternalModelUpdate(model, () => model.setValue(content)); if (selection) editor.setSelection(selection); - previousContentRef.current = content; - applyingExternalChangeRef.current = false; - }, [content]); + appliedContentRevisionRef.current = contentRevision; + markMonacoModelContentRevision(model, contentRevision); + }, [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..bc1840cb8 100644 --- a/src/features/editor/engines/monaco/content-sync.ts +++ b/src/features/editor/engines/monaco/content-sync.ts @@ -1,22 +1,16 @@ -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); - } - - snapshots.push(content); - - while (snapshots.length > MAX_PENDING_LOCAL_SNAPSHOTS) { - snapshots.shift(); +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); } } -export function consumeLocalContentSnapshot(snapshots: string[], content: string): boolean { - const index = snapshots.indexOf(content); - if (index === -1) return false; - - snapshots.splice(index, 1); - return true; +export function isExternalModelUpdate(model: object): boolean { + return (externalModelUpdateDepth.get(model) ?? 0) > 0; } diff --git a/src/features/editor/engines/monaco/model-lifecycle.ts b/src/features/editor/engines/monaco/model-lifecycle.ts index 004d51a04..5cf8c9fd7 100644 --- a/src/features/editor/engines/monaco/model-lifecycle.ts +++ b/src/features/editor/engines/monaco/model-lifecycle.ts @@ -3,15 +3,20 @@ import type * as Monaco from "monaco-editor"; interface SharedMonacoModel { model: Monaco.editor.ITextModel; + sessionId: string; + contentRevision: number; 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; + sessionId: string; + contentRevision: number; release: () => void; } @@ -19,12 +24,19 @@ export function acquireMonacoModel( content: string, languageId: string, uri: Monaco.Uri, + contentRevision = 0, ): AcquiredMonacoModel { const key = uri.toString(); 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++}`, + contentRevision, + referenceCount: 0, + releaseTimer: null, + }; sharedModels.set(key, entry); } @@ -38,6 +50,8 @@ export function acquireMonacoModel( let released = false; return { model, + sessionId: entry.sessionId, + contentRevision: entry.contentRevision, release: () => { if (released) return; released = true; @@ -60,3 +74,15 @@ export function acquireMonacoModel( }, }; } + +export function markMonacoModelContentRevision( + model: Monaco.editor.ITextModel, + contentRevision: number, +): void { + for (const entry of sharedModels.values()) { + if (entry.model === model) { + entry.contentRevision = contentRevision; + return; + } + } +} 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..e696749ba 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); }); @@ -62,23 +57,21 @@ export const useLspIntegration = ({ }); } - 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 +83,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..ff0c20165 100644 --- a/src/features/editor/hooks/use-tokenizer.ts +++ b/src/features/editor/hooks/use-tokenizer.ts @@ -319,6 +319,7 @@ export function useTokenizer({ try { const result = await tokenizerWorkerClient.tokenize({ bufferId, + latestKey: `editor-tokenizer:${bufferId}`, content: normalizedText, languageId, wasmPath: languageAssets.wasmPath, @@ -414,6 +415,7 @@ export function useTokenizer({ const result = await tokenizerWorkerClient.tokenize({ bufferId, + latestKey: `editor-tokenizer:${bufferId}`, content: normalizedText, languageId, wasmPath: languageAssets.wasmPath, 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..b47dfcbdc 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"; @@ -162,11 +164,107 @@ 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 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), + ); + } + + private flushDocumentChanges(filePath: string): Promise { + const timer = this.documentChangeTimers.get(filePath); + if (timer) clearTimeout(timer); + this.documentChangeTimers.delete(filePath); + + const batches = this.documentChangeQueues.get(filePath); + if (!batches?.length) { + return this.documentChangeSendChains.get(filePath) ?? Promise.resolve(); + } + this.documentChangeQueues.delete(filePath); + + 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: batches.map( + ({ modelSessionId, modelVersionId, changes, isEolChange, isFlush, fullContent }) => ({ + modelSessionId, + modelVersionId, + changes, + isEolChange, + isFlush, + fullContent, + }), + ), + }); + this.documentVersions.set(filePath, version); + this.documentChangeRetries.delete(filePath); + }) + .catch((error) => { + const retries = this.documentChangeRetries.get(filePath) ?? 0; + if (this.openDocuments.has(filePath) && retries < 2) { + this.documentChangeRetries.set(filePath, retries + 1); + const queued = this.documentChangeQueues.get(filePath) ?? []; + this.documentChangeQueues.set(filePath, [...batches, ...queued]); + this.documentChangeTimers.set( + filePath, + setTimeout(() => void this.flushDocumentChanges(filePath), 80 * (retries + 1)), + ); + } + logger.error("LSPClient", "LSP incremental document change error:", error); + }); + 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); + } + + private async flushDocumentChangesBeforeOperation(filePath: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + await this.flushDocumentChanges(filePath); + if (!this.documentChangeRetries.has(filePath)) return; + if (!this.documentChangeQueues.has(filePath)) break; + } + throw new Error(`Could not synchronize pending LSP changes for ${filePath}`); } /** @@ -892,7 +990,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 +1011,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 +1025,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 +1047,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 +1106,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 +1125,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 +1135,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 +1145,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 +1162,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 +1180,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 +1196,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 +1211,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 +1227,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 +1240,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 +1252,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 +1264,12 @@ export class LspClient { async getOnTypeFormattingTriggerCharacters(filePath: string): Promise { try { - return await invoke("lsp_get_on_type_formatting_trigger_characters", { + 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 +1285,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 +1315,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 +1342,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 +1421,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 +1434,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 +1445,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 +1465,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 +1496,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 +1528,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 +1550,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 +1584,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 +1624,13 @@ export class LspClient { actionPayload = withoutWorkspaceEdit(actionPayload); } - const result = await invoke("lsp_apply_code_action", { + const result = await this.invokeForDocument( + "lsp_apply_code_action", + { filePath, actionPayload, - }); + }, + ); return appliedEdit && !result.applied ? { applied: true, reason: result.reason } : result; } catch (error) { @@ -1524,17 +1643,32 @@ export class LspClient { } async notifyDocumentOpen(filePath: string, content: string): Promise { - try { + if (this.openDocuments.has(filePath)) return; + const opening = this.openingDocuments.get(filePath); + if (opening) return opening; + + 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 +1676,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,11 +1684,15 @@ export class LspClient { } async getJavaClassFileContents(filePath: string, uri: string): Promise { - return await invoke("lsp_get_java_class_file_contents", { filePath, uri }); + return await this.invokeForDocument("lsp_get_java_class_file_contents", { + filePath, + uri, + }); } async notifyDocumentChange(filePath: string, content: string, version: number): Promise { try { + await this.flushDocumentChangesBeforeOperation(filePath); this.openDocuments.add(filePath); this.documentVersions.set(filePath, version); await invoke("lsp_document_change", { @@ -1567,26 +1705,57 @@ export class LspClient { } } - async notifyDocumentSave(filePath: string, content: string): Promise { + async notifyDocumentSave(filePath: string, content?: string): Promise { + void content; 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); 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 22f34cadc..aad6f77b5 100644 --- a/src/features/editor/markdown/code-highlight.ts +++ b/src/features/editor/markdown/code-highlight.ts @@ -3,7 +3,7 @@ import { fetchHighlightQuery, getLanguageAssetConfig, } from "@/features/editor/lib/wasm-parser/extension-assets"; -import { tokenizeCode } 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 { normalizeCodeFenceLanguage } from "./language-map"; @@ -14,6 +14,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", @@ -161,6 +163,7 @@ function resolveHighlightLanguage(language: string): string | null { async function tokenizeForLanguage( code: string, languageId: string, + requestKey: string, ): Promise { try { const cached = await indexedDBParserCache.get(languageId); @@ -187,15 +190,19 @@ 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, @@ -207,32 +214,74 @@ async function tokenizeForLanguage( } } +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; + if (cached) { + TOKEN_CACHE.delete(cacheKey); + TOKEN_CACHE.set(cacheKey, cached); + return cached; + } - const treeSitterSegments = await tokenizeForLanguage(code, languageId); - const segments = - treeSitterSegments && treeSitterSegments.length > 0 - ? treeSitterSegments - : fallbackSegmentsForLanguage(code, languageId); + const pending = TOKEN_REQUESTS.get(cacheKey); + if (pending) return pending; - TOKEN_CACHE.set(cacheKey, segments); - return segments; + const tokenRequest = (async () => { + const treeSitterSegments = await tokenizeForLanguage( + code, + languageId, + requestKey ?? `markdown-code:${languageId}:${contentKey}`, + ); + 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 matches: { full: string; lang: string; code: string }[] = [];
 
@@ -247,9 +296,13 @@ export async function highlightMarkdownCodeBlocks(html: string): Promise
   if (matches.length === 0) return html;
 
   const highlightedMatches = await Promise.all(
-    matches.map(async (match) => {
+    matches.map(async (match, index) => {
       const rawCode = match.code.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
-      const segments = await getCodeHighlightSegments(rawCode, match.lang);
+      const segments = await getCodeHighlightSegments(
+        rawCode,
+        match.lang,
+        requestKey ? `${requestKey}:${index}` : undefined,
+      );
       if (segments.length === 0) return null;
 
       const highlighted = renderHighlightedCodeHtml(rawCode, segments);
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 2c099ce2a..04ef18458 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 6fb7efca1..7e29e81a4 100644
--- a/src/features/editor/stores/buffer.store.ts
+++ b/src/features/editor/stores/buffer.store.ts
@@ -50,6 +50,15 @@ 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,
+  normalizeEditorContentEol,
+} from "@/features/editor/utils/editor-text-changes";
 import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store";
 import {
   isEditorContent,
@@ -62,6 +71,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";
@@ -165,6 +179,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;
@@ -1241,11 +1260,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;
             }
@@ -1268,6 +1291,88 @@ 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,
+            };
+          }
+
+          let nextContent = applyEditorTextChanges(buffer.content, batch.changes);
+          if (nextContent === null) {
+            return {
+              accepted: false,
+              synchronized: false,
+              contentRevision: buffer.contentRevision ?? 0,
+            };
+          }
+          if (batch.isEolChange) {
+            nextContent = normalizeEditorContentEol(nextContent, batch.eol);
+          }
+
+          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[]) => {
@@ -1316,12 +1421,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..9f8310067 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,
@@ -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..b63bfa995
--- /dev/null
+++ b/src/features/editor/tests/lsp-document-sync.test.ts
@@ -0,0 +1,181 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { invoke } from "@tauri-apps/api/core";
+import { LspClient } from "../lsp/lsp-client";
+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;
+  };
+
+  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();
+  });
+
+  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);
+  });
+});
diff --git a/src/features/editor/tests/markdown-code-highlight.test.ts b/src/features/editor/tests/markdown-code-highlight.test.ts
index c9e3db3db..c1966fb2d 100644
--- a/src/features/editor/tests/markdown-code-highlight.test.ts
+++ b/src/features/editor/tests/markdown-code-highlight.test.ts
@@ -14,14 +14,17 @@ 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";
 
 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
', @@ -36,5 +39,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-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..14570b57b 100644 --- a/src/features/editor/tests/monaco-model-lifecycle.test.ts +++ b/src/features/editor/tests/monaco-model-lifecycle.test.ts @@ -12,7 +12,10 @@ vi.mock("monaco-editor", () => ({ }, })); -import { acquireMonacoModel } from "../engines/monaco/model-lifecycle"; +import { + acquireMonacoModel, + markMonacoModelContentRevision, +} from "../engines/monaco/model-lifecycle"; function createTextModel() { let disposed = false; @@ -95,4 +98,19 @@ describe("Monaco model lifecycle", () => { vi.advanceTimersByTime(5_000); expect(model.dispose).toHaveBeenCalledTimes(1); }); + + it("exposes the last synchronized revision when a shared model remounts", () => { + const model = createTextModel(); + getModel.mockReturnValue(null); + createModel.mockReturnValue(model); + const first = acquireMonacoModel("one", "text", uri as never, 1); + markMonacoModelContentRevision(first.model as never, 3); + + const second = acquireMonacoModel("new external content", "text", uri as never, 4); + expect(second.contentRevision).toBe(3); + 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..37ef3cb4b 100644 --- a/src/features/editor/types/editor.types.ts +++ b/src/features/editor/types/editor.types.ts @@ -26,6 +26,37 @@ 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; + 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..658eb7078 --- /dev/null +++ b/src/features/editor/utils/editor-text-changes.ts @@ -0,0 +1,48 @@ +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(""); +} + +export function normalizeEditorContentEol(content: string, eol: "\n" | "\r\n"): string { + return content.replace(/\r\n|\r|\n/g, eol); +} 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 1a5337966..4b61ebe76 100644 --- a/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/src/features/file-explorer/components/file-explorer-tree.tsx @@ -34,10 +34,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"; @@ -191,6 +193,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); @@ -318,6 +321,19 @@ function FileExplorerTreeComponent({ [files, rootFolderPath], ); + useEffect( + () => + subscribeToFileTreeGitIgnoreCacheInvalidation((path) => { + if (!rootFolderPath || (path && !pathStartsWithRoot(path, rootFolderPath))) return; + setGitIgnoreCacheVersion((version) => version + 1); + }), + [rootFolderPath], + ); + + useEffect(() => { + invalidateFileTreeGitIgnoreCache(); + }, [rootFolderPath]); + useEffect(() => { let cancelled = false; @@ -327,26 +343,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)); } }; @@ -355,7 +355,7 @@ function FileExplorerTreeComponent({ return () => { cancelled = true; }; - }, [gitIgnoreFileReferences, rootFolderPath]); + }, [gitIgnoreCacheVersion, gitIgnoreFileReferences, 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/lib/visible-file-tree-rows.ts b/src/features/file-explorer/lib/visible-file-tree-rows.ts index 03a78756f..6e80d65ec 100644 --- a/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -41,6 +41,21 @@ export interface FilterFileTreeEntriesOptions { showHiddenFiles: boolean; } +const normalizedSortKeyCache = new WeakMap(); +const sortedEntriesCache = new WeakMap< + readonly FileEntry[], + Map +>(); + +function getNormalizedSortKey(entry: FileEntry): string { + const cached = normalizedSortKeyCache.get(entry); + if (cached !== undefined) return cached; + + const key = entry.name.toLowerCase(); + normalizedSortKeyCache.set(entry, key); + return key; +} + export function filterFileTreeEntries( files: FileEntry[], options: FilterFileTreeEntriesOptions, @@ -137,13 +152,20 @@ function sortFileTreeEntriesForDisplay( entries: readonly FileEntry[], sortOrder: FileTreeSortOrder, ): FileEntry[] { - return [...entries].sort((left, right) => { + const cached = sortedEntriesCache.get(entries)?.get(sortOrder); + if (cached) return cached; + + const sorted = [...entries].sort((left, right) => { if (sortOrder === "folders-first" && left.isDir !== right.isDir) { return left.isDir ? -1 : 1; } - return left.name.toLowerCase().localeCompare(right.name.toLowerCase()); + return getNormalizedSortKey(left).localeCompare(getNormalizedSortKey(right)); }); + const entriesCache = sortedEntriesCache.get(entries) ?? new Map(); + entriesCache.set(sortOrder, sorted); + sortedEntriesCache.set(entries, entriesCache); + return sorted; } export function buildVisibleFileTreeRows( 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 f3e33875d..7c4e5179b 100644 --- a/src/features/file-system/services/file-watcher-listener.ts +++ b/src/features/file-system/services/file-watcher-listener.ts @@ -3,6 +3,7 @@ import { dirname } from "@tauri-apps/api/path"; 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 { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { useFileSystemStore } from "../stores/file-system.store"; import { useFileWatcherStore } from "../stores/file-watcher.store"; @@ -33,6 +34,7 @@ export async function initializeFileWatcherListener() { unlistenFileChanged = await listen("file-changed", async (event) => { const { path, event_type } = event.payload; + invalidateFileTreeGitIgnoreCache(path); const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); const parentDirectory = await dirname(path); 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 8791bad7f..4b257efe5 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; @@ -1614,12 +1644,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, @@ -1628,56 +1657,76 @@ 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)); } @@ -1878,6 +1927,9 @@ const createFileSystemStore = (workspaceId: string): StoreApi { @@ -1892,6 +1944,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 @@ -2309,6 +2369,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..9ef82c44c --- /dev/null +++ b/src/features/file-system/tests/subtree-preload-queue.test.ts @@ -0,0 +1,34 @@ +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 8e9dbc063..4130399e1 100644 --- a/src/features/panes/types/pane-content.types.ts +++ b/src/features/panes/types/pane-content.types.ts @@ -64,6 +64,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 8848a2510..f05b52a98 100644 --- a/src/features/terminal/hooks/use-terminal-connection.ts +++ b/src/features/terminal/hooks/use-terminal-connection.ts @@ -7,6 +7,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, @@ -56,7 +57,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( @@ -134,7 +134,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(); @@ -170,36 +169,29 @@ 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(() => { + terminal.writeln(`\r\n\x1b[31mError: ${event.message}\x1b[0m`); + }); return; } @@ -208,34 +200,37 @@ export function useTerminalConnection({ return; } - void closeTerminalConnection({ connectionId, remoteConnectionId }).catch(() => {}); - releaseTerminalEventChannel(connectionId); + void outputBuffer.whenDrained().then(() => { + void closeTerminalConnection({ connectionId, remoteConnectionId }).catch(() => {}); + releaseTerminalEventChannel(connectionId); - if (hadTerminalErrorRef.current) { - terminal.writeln("\x1b[90mOpen a new terminal tab or close this one manually.\x1b[0m"); - return; - } + if (hadTerminalErrorRef.current) { + terminal.writeln("\x1b[90mOpen a new terminal tab or close this one manually.\x1b[0m"); + return; + } - 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; + if (exitCode === 0 && signal == null) { + onTerminalExitRef.current?.(sessionId); + 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"); + 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.flush(); 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 6c27edf60..cff14360e 100644 --- a/src/features/terminal/stores/terminal.store.ts +++ b/src/features/terminal/stores/terminal.store.ts @@ -23,8 +23,13 @@ const createTerminalStore = () => actions: { updateSession: (sessionId: string, updates: Partial) => { 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 }; }); @@ -36,6 +41,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 }; @@ -43,7 +49,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..d867dc55d --- /dev/null +++ b/src/features/terminal/tests/terminal-output-buffer.test.ts @@ -0,0 +1,78 @@ +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(); + }); +}); 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..95777e449 --- /dev/null +++ b/src/features/terminal/utils/terminal-output-buffer.ts @@ -0,0 +1,113 @@ +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; +} + +export function createTerminalOutputBuffer({ + maxBatchBytes = DEFAULT_MAX_BATCH_BYTES, + onQueuedBytesChange, + onWriteError, + schedule = queueMicrotask, + write, +}: TerminalOutputBufferOptions): TerminalOutputBuffer { + const chunks: Uint8Array[] = []; + const drainedResolvers = new Set<() => void>(); + let firstChunkOffset = 0; + let queuedByteCount = 0; + let writeInProgress = false; + let flushScheduled = false; + + const notifyQueuedBytes = () => onQueuedBytesChange?.(queuedByteCount); + const resolveDrained = () => { + if (queuedByteCount !== 0 || writeInProgress || chunks.length !== 0) return; + for (const resolve of drainedResolvers) resolve(); + drainedResolvers.clear(); + }; + + const takeBatch = (): Uint8Array => { + const size = Math.min( + maxBatchBytes, + chunks.reduce((total, chunk, index) => { + const available = chunk.byteLength - (index === 0 ? firstChunkOffset : 0); + return Math.min(maxBatchBytes, total + available); + }, 0), + ); + const batch = new Uint8Array(size); + let written = 0; + while (written < size) { + const chunk = chunks[0]; + if (!chunk) break; + const available = chunk.byteLength - firstChunkOffset; + const length = Math.min(available, size - written); + batch.set(chunk.subarray(firstChunkOffset, firstChunkOffset + length), written); + written += length; + firstChunkOffset += length; + if (firstChunkOffset === chunk.byteLength) { + chunks.shift(); + firstChunkOffset = 0; + } + } + return batch; + }; + + const drain = () => { + flushScheduled = false; + if (writeInProgress || chunks.length === 0) { + resolveDrained(); + return; + } + const batch = takeBatch(); + writeInProgress = true; + let completed = false; + const complete = () => { + if (completed) 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 (flushScheduled || writeInProgress) return; + flushScheduled = true; + schedule(drain); + }; + + return { + enqueue: (data) => { + if (data.byteLength === 0) return; + chunks.push(data); + queuedByteCount += data.byteLength; + notifyQueuedBytes(); + scheduleFlush(); + }, + flush: drain, + queuedBytes: () => queuedByteCount, + whenDrained: () => { + if (queuedByteCount === 0 && !writeInProgress && chunks.length === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => drainedResolvers.add(resolve)); + }, + }; +} From c22a1d31f1429d9d53d7f1da680306864299b250 Mon Sep 17 00:00:00 2001 From: SnaetWarre Date: Mon, 21 Sep 2026 17:55:50 +0200 Subject: [PATCH 2/5] Fix frontend formatting to unblock CI Apply vp formatting to the six flagged frontend files so vp check passes again. The changes are whitespace and line-wrap only with no behavior change. --- src/features/editor/hooks/use-lsp-integration.ts | 1 - src/features/editor/lsp/lsp-client.ts | 6 +++--- src/features/editor/tests/lsp-document-sync.test.ts | 4 +--- src/features/file-explorer/lib/visible-file-tree-rows.ts | 5 +---- src/features/file-system/stores/file-system.store.ts | 7 +------ .../file-system/tests/subtree-preload-queue.test.ts | 6 +----- 6 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/features/editor/hooks/use-lsp-integration.ts b/src/features/editor/hooks/use-lsp-integration.ts index e696749ba..4045e8f66 100644 --- a/src/features/editor/hooks/use-lsp-integration.ts +++ b/src/features/editor/hooks/use-lsp-integration.ts @@ -56,7 +56,6 @@ export const useLspIntegration = ({ console.error("LSP stop for file error:", error); }); } - }; if (lspClient.isDocumentOpen(filePath)) { diff --git a/src/features/editor/lsp/lsp-client.ts b/src/features/editor/lsp/lsp-client.ts index b47dfcbdc..2c02b36ac 100644 --- a/src/features/editor/lsp/lsp-client.ts +++ b/src/features/editor/lsp/lsp-client.ts @@ -1267,7 +1267,7 @@ export class LspClient { return await this.invokeForDocument( "lsp_get_on_type_formatting_trigger_characters", { - filePath, + filePath, }, ); } catch (error) { @@ -1627,8 +1627,8 @@ export class LspClient { const result = await this.invokeForDocument( "lsp_apply_code_action", { - filePath, - actionPayload, + filePath, + actionPayload, }, ); diff --git a/src/features/editor/tests/lsp-document-sync.test.ts b/src/features/editor/tests/lsp-document-sync.test.ts index b63bfa995..f6bf73e2d 100644 --- a/src/features/editor/tests/lsp-document-sync.test.ts +++ b/src/features/editor/tests/lsp-document-sync.test.ts @@ -122,9 +122,7 @@ describe("LSP incremental document synchronization", () => { 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", - ]); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual(["lsp_document_open"]); finishOpen?.(); await opening; diff --git a/src/features/file-explorer/lib/visible-file-tree-rows.ts b/src/features/file-explorer/lib/visible-file-tree-rows.ts index 6e80d65ec..976b473b2 100644 --- a/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -42,10 +42,7 @@ export interface FilterFileTreeEntriesOptions { } const normalizedSortKeyCache = new WeakMap(); -const sortedEntriesCache = new WeakMap< - readonly FileEntry[], - Map ->(); +const sortedEntriesCache = new WeakMap>(); function getNormalizedSortKey(entry: FileEntry): string { const cached = normalizedSortKeyCache.get(entry); diff --git a/src/features/file-system/stores/file-system.store.ts b/src/features/file-system/stores/file-system.store.ts index 4b257efe5..144d6e2f6 100644 --- a/src/features/file-system/stores/file-system.store.ts +++ b/src/features/file-system/stores/file-system.store.ts @@ -1665,12 +1665,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi { 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(batch.map((item) => item.path)).toEqual(["/repo/dir-0", "/repo/dir-1", "/repo/dir-2"]); expect(queue).toHaveLength(9); }); From 9366c5224321ba76f4fb5f8ee2df9535f0856fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C3=96zg=C3=BCl?= Date: Sun, 27 Sep 2026 21:40:36 +0300 Subject: [PATCH 3/5] Keep editor buffers identical to their Monaco model Monaco drops a BOM and rewrites lone CR and mixed line endings when it builds a model, so its change offsets did not match a buffer read from disk and edits were patched into the wrong place and saved. Deltas are now only applied while the buffer holds the model's exact text, and the buffer checks the resulting length. Otherwise the model's full text is sent instead, and a rejected delta no longer resets the model, which dropped keystrokes, undo history and the cursor. --- .../editor/components/monaco-editor.tsx | 112 ++++----- .../editor/engines/monaco/content-sync.ts | 14 ++ .../engines/monaco/document-change-batch.ts | 98 ++++++++ .../editor/engines/monaco/model-lifecycle.ts | 18 +- src/features/editor/stores/buffer.store.ts | 23 +- .../editor/tests/monaco-buffer-sync.test.ts | 216 ++++++++++++++++++ .../tests/monaco-model-lifecycle.test.ts | 14 +- src/features/editor/types/editor.types.ts | 3 + .../editor/utils/editor-text-changes.ts | 4 - src/vite-env.d.ts | 39 ++++ 10 files changed, 447 insertions(+), 94 deletions(-) create mode 100644 src/features/editor/engines/monaco/document-change-batch.ts create mode 100644 src/features/editor/tests/monaco-buffer-sync.test.ts diff --git a/src/features/editor/components/monaco-editor.tsx b/src/features/editor/components/monaco-editor.tsx index 01b0619ca..278a5a7c1 100644 --- a/src/features/editor/components/monaco-editor.tsx +++ b/src/features/editor/components/monaco-editor.tsx @@ -72,7 +72,12 @@ import { getLanguageIdFromPath } from "../utils/language-id"; import { editorAPI } from "../extensions/api"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; -import { isExternalModelUpdate, runWithExternalModelUpdate } from "../engines/monaco/content-sync"; +import { + isExternalModelUpdate, + modelMatchesContent, + runWithExternalModelUpdate, +} from "../engines/monaco/content-sync"; +import { deliverModelContentChange } from "../engines/monaco/document-change-batch"; import { clampMonacoHoverWidgets, mutationsContainMonacoHoverWidget, @@ -80,10 +85,7 @@ import { } from "../engines/monaco/hover-widgets"; import { toMonacoLanguageId } from "../engines/monaco/language"; import { ensureMonacoLanguageTokenizer } from "../engines/monaco/language-contributions"; -import { - acquireMonacoModel, - markMonacoModelContentRevision, -} from "../engines/monaco/model-lifecycle"; +import { acquireMonacoModel } from "../engines/monaco/model-lifecycle"; import { getEditorBottomScrollPadding } from "../engines/monaco/scroll-padding"; import { getMonacoScrollbarOptions } from "../engines/monaco/scrollbar-options"; import { @@ -205,6 +207,7 @@ export function MonacoEditor({ 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([]); @@ -794,12 +797,13 @@ export function MonacoEditor({ }, ); - const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri, contentRevision); + const acquiredModel = acquireMonacoModel(content, monacoLanguageId, modelUri); const model = acquiredModel.model; - if (acquiredModel.contentRevision !== contentRevision) { + // 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)); - markMonacoModelContentRevision(model, contentRevision); } + bufferMatchesModelRef.current = modelMatchesContent(model, content); modelSessionIdRef.current = acquiredModel.sessionId; const editor = monacoEditor.create(container, { model, @@ -1142,52 +1146,43 @@ export function MonacoEditor({ editor.onDidChangeModelContent((event) => { if (isExternalModelUpdate(model)) return; const editorState = useEditorStateStore.getState(); - const changes = event.changes.map((change) => ({ - 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, - })); - const batch: EditorDocumentChangeBatch = { - sourceId: sourceIdRef.current, - modelSessionId: modelSessionIdRef.current, - modelVersionId: event.versionId, - changes, - eol: event.eol === "\r\n" ? "\r\n" : "\n", - isEolChange: event.isEolChange, - isFlush: event.isFlush, - isUndoing: event.isUndoing, - isRedoing: event.isRedoing, - }; - const result = latestDocumentChangeRef.current?.( - batch, - editorState.cursorPosition, - editorState.selection, - ); - if (result?.synchronized) { - appliedContentRevisionRef.current = Math.max( - appliedContentRevisionRef.current, - result.contentRevision, - ); - markMonacoModelContentRevision(model, result.contentRevision); - } else if (result) { - const currentBuffer = getBufferById(useBufferStore.getState().buffers, activeBufferId); - if (currentBuffer?.type === "editor") { - runWithExternalModelUpdate(model, () => model.setValue(currentBuffer.content)); - appliedContentRevisionRef.current = currentBuffer.contentRevision ?? 0; - markMonacoModelContentRevision(model, appliedContentRevisionRef.current); + 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) { - const nextContent = model.getValue(); latestContentChangeRef.current( - nextContent, + model.getValue(), undefined, editorState.cursorPosition, editorState.selection, - changes.length === 1 ? { contentChange: changes[0] } : undefined, + 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(); @@ -1586,12 +1581,21 @@ export function MonacoEditor({ const model = modelRef.current; if (!editor || !model) return; - if (contentRevision <= appliedContentRevisionRef.current) return; - const selection = editor.getSelection(); - runWithExternalModelUpdate(model, () => model.setValue(content)); - if (selection) editor.setSelection(selection); - appliedContentRevisionRef.current = contentRevision; - markMonacoModelContentRevision(model, contentRevision); + // 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); + } + bufferMatchesModelRef.current = modelMatchesContent(model, content); }, [content, contentRevision]); useEffect(() => { diff --git a/src/features/editor/engines/monaco/content-sync.ts b/src/features/editor/engines/monaco/content-sync.ts index bc1840cb8..710d8652f 100644 --- a/src/features/editor/engines/monaco/content-sync.ts +++ b/src/features/editor/engines/monaco/content-sync.ts @@ -14,3 +14,17 @@ export function runWithExternalModelUpdate(model: object, update: () => T): T export function isExternalModelUpdate(model: object): boolean { return (externalModelUpdateDepth.get(model) ?? 0) > 0; } + +interface ModelTextSource { + getValueLength: () => number; + getValue: () => string; +} + +/** + * 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 5cf8c9fd7..744b6c43c 100644 --- a/src/features/editor/engines/monaco/model-lifecycle.ts +++ b/src/features/editor/engines/monaco/model-lifecycle.ts @@ -4,7 +4,6 @@ import type * as Monaco from "monaco-editor"; interface SharedMonacoModel { model: Monaco.editor.ITextModel; sessionId: string; - contentRevision: number; referenceCount: number; releaseTimer: ReturnType | null; } @@ -15,8 +14,8 @@ 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; - contentRevision: number; release: () => void; } @@ -24,7 +23,6 @@ export function acquireMonacoModel( content: string, languageId: string, uri: Monaco.Uri, - contentRevision = 0, ): AcquiredMonacoModel { const key = uri.toString(); let entry = sharedModels.get(key); @@ -33,7 +31,6 @@ export function acquireMonacoModel( entry = { model, sessionId: `monaco-model-${nextModelSessionId++}`, - contentRevision, referenceCount: 0, releaseTimer: null, }; @@ -51,7 +48,6 @@ export function acquireMonacoModel( return { model, sessionId: entry.sessionId, - contentRevision: entry.contentRevision, release: () => { if (released) return; released = true; @@ -74,15 +70,3 @@ export function acquireMonacoModel( }, }; } - -export function markMonacoModelContentRevision( - model: Monaco.editor.ITextModel, - contentRevision: number, -): void { - for (const entry of sharedModels.values()) { - if (entry.model === model) { - entry.contentRevision = contentRevision; - return; - } - } -} diff --git a/src/features/editor/stores/buffer.store.ts b/src/features/editor/stores/buffer.store.ts index aeec363a8..5535b06e6 100644 --- a/src/features/editor/stores/buffer.store.ts +++ b/src/features/editor/stores/buffer.store.ts @@ -55,10 +55,7 @@ import type { EditorDocumentChangeResult, } from "@/features/editor/types/editor.types"; import { publishEditorDocumentChange } from "@/features/editor/services/editor-document-events"; -import { - applyEditorTextChanges, - normalizeEditorContentEol, -} from "@/features/editor/utils/editor-text-changes"; +import { applyEditorTextChanges } from "@/features/editor/utils/editor-text-changes"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { isEditorContent, @@ -1335,17 +1332,25 @@ const createBufferStore = (workspaceId: string) => { }; } - let nextContent = applyEditorTextChanges(buffer.content, batch.changes); - if (nextContent === null) { + // 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, }; } - if (batch.isEolChange) { - nextContent = normalizeEditorContentEol(nextContent, batch.eol); - } const contentRevision = (buffer.contentRevision ?? 0) + 1; let promotedPreviewBufferId: string | null = null; 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-model-lifecycle.test.ts b/src/features/editor/tests/monaco-model-lifecycle.test.ts index 14570b57b..b1998faf5 100644 --- a/src/features/editor/tests/monaco-model-lifecycle.test.ts +++ b/src/features/editor/tests/monaco-model-lifecycle.test.ts @@ -12,10 +12,7 @@ vi.mock("monaco-editor", () => ({ }, })); -import { - acquireMonacoModel, - markMonacoModelContentRevision, -} from "../engines/monaco/model-lifecycle"; +import { acquireMonacoModel } from "../engines/monaco/model-lifecycle"; function createTextModel() { let disposed = false; @@ -99,15 +96,12 @@ describe("Monaco model lifecycle", () => { expect(model.dispose).toHaveBeenCalledTimes(1); }); - it("exposes the last synchronized revision when a shared model remounts", () => { + 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, 1); - markMonacoModelContentRevision(first.model as never, 3); - - const second = acquireMonacoModel("new external content", "text", uri as never, 4); - expect(second.contentRevision).toBe(3); + const first = acquireMonacoModel("one", "text", uri as never); + const second = acquireMonacoModel("one", "text", uri as never); expect(second.sessionId).toBe(first.sessionId); first.release(); diff --git a/src/features/editor/types/editor.types.ts b/src/features/editor/types/editor.types.ts index 37ef3cb4b..8d8bf0496 100644 --- a/src/features/editor/types/editor.types.ts +++ b/src/features/editor/types/editor.types.ts @@ -43,6 +43,9 @@ export interface EditorDocumentChangeBatch { 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; } diff --git a/src/features/editor/utils/editor-text-changes.ts b/src/features/editor/utils/editor-text-changes.ts index 658eb7078..ca188e89a 100644 --- a/src/features/editor/utils/editor-text-changes.ts +++ b/src/features/editor/utils/editor-text-changes.ts @@ -42,7 +42,3 @@ export function applyEditorTextChanges( pieces.push(content.slice(sourceOffset)); return pieces.join(""); } - -export function normalizeEditorContentEol(content: string, eol: "\n" | "\r\n"): string { - return content.replace(/\r\n|\r|\n/g, eol); -} 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; From 64d46ec1fc4b8207594412cc6aa89d1993ca88dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C3=96zg=C3=BCl?= Date: Sun, 27 Sep 2026 21:40:43 +0300 Subject: [PATCH 4/5] Resync LSP documents instead of failing after lost edits After two failed retries the queued edits were dropped and every later request threw, while the backend copy drifted from the editor. The client now replaces the server's copy with the editor text and keeps answering requests. The backend mirror edits in place with an undo log instead of cloning the document per batch, bounds retired model sessions, and spawns its debounce through Tauri's runtime because the batch command runs without a Tokio context. Documents open with the store's text so edits queued during the open are not lost. --- crates/lsp/src/document_sync.rs | 374 ++++++++++++++---- crates/lsp/src/manager.rs | 11 +- src/features/editor/lsp/lsp-client.ts | 128 +++--- .../editor/stores/editor-app.store.ts | 2 +- .../editor/tests/lsp-document-sync.test.ts | 84 ++++ 5 files changed, 467 insertions(+), 132 deletions(-) diff --git a/crates/lsp/src/document_sync.rs b/crates/lsp/src/document_sync.rs index ebbd0b956..9641c719c 100644 --- a/crates/lsp/src/document_sync.rs +++ b/crates/lsp/src/document_sync.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail}; use lsp_types::{Position, Range, TextDocumentContentChangeEvent}; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, VecDeque}, sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, @@ -42,15 +42,22 @@ pub(crate) enum SyncMode { Incremental, } -#[derive(Debug, Clone)] +/// 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: HashSet, + 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, @@ -60,6 +67,19 @@ struct DocumentSession { 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)] @@ -87,10 +107,11 @@ impl DocumentSessions { content, line_starts, model_session_id: None, - retired_model_sessions: HashSet::new(), + 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, @@ -108,6 +129,8 @@ impl DocumentSessions { 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, @@ -117,17 +140,19 @@ impl DocumentSessions { let session = sessions .get_mut(file_path) .context("No open LSP document session for this file")?; - let mut staged = session.clone(); + let mut transaction = Transaction::begin(session); for batch in batches { - apply_batch(&mut staged, batch)?; + if let Err(error) = apply_batch(session, &mut transaction, batch) { + transaction.rollback(session); + return Err(error); + } } - let result = ( - staged.epoch, - staged.generation, - staged.lsp_version.saturating_add(1), - ); - *session = staged; - Ok(result) + session.generation = session.generation.wrapping_add(1); + Ok(( + session.epoch, + session.generation, + session.lsp_version.saturating_add(1), + )) } #[cfg(test)] @@ -141,7 +166,7 @@ impl DocumentSessions { &self, file_path: &str, expected: Option<(u64, u64)>, - emit: impl FnOnce(&PendingDocumentChanges) -> Result<()>, + emit: impl FnOnce(&mut PendingDocumentChanges) -> Result<()>, ) -> Result<()> { let (epoch, emission_lock) = { let sessions = self.inner.lock().unwrap(); @@ -165,19 +190,18 @@ impl DocumentSessions { } take_pending(session) }; - if let Some(pending) = pending - && let Err(error) = emit(&pending) + 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 !session - .pending_changes - .iter() - .any(|change| change.range.is_none()) - { + 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; @@ -198,7 +222,77 @@ impl DocumentSessions { } } -fn apply_batch(session: &mut DocumentSession, batch: DocumentChangeBatch) -> Result<()> { +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) @@ -214,93 +308,94 @@ fn apply_batch(session: &mut DocumentSession, batch: DocumentChangeBatch) -> Res bail!("Change belongs to a retired LSP model session"); } - let mut next_content = session.content.clone(); - let mut next_line_starts = session.line_starts.clone(); - let mut next_pending = session.pending_changes.clone(); if replacement { - next_content = batch + let next_content = batch .full_content .context("Full document content is required for replacement changes")?; - next_line_starts = collect_line_starts(&next_content); - next_pending.clear(); - next_pending.push(TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: next_content.clone(), - }); - } else { - if batch.changes.is_empty() { - bail!("Incremental document change batch is empty"); + 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(()); + } - 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"); - } + 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 { - apply_change(&mut next_content, &mut next_line_starts, &change)?; - next_pending.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, - }); + } + 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 !replacement { - 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.insert(previous_session); + 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; } - session.content = next_content; - session.line_starts = next_line_starts; - session.pending_changes = next_pending; - session.generation = session.generation.wrapping_add(1); + session.model_version_id = batch.model_version_id; Ok(()) } fn take_pending(session: &mut DocumentSession) -> Option { - if session.pending_changes.is_empty() || session.sync_mode == SyncMode::None { + 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 changes = if session.sync_mode == SyncMode::Incremental - && session - .pending_changes - .iter() - .all(|change| change.range.is_some()) - { - std::mem::take(&mut session.pending_changes) - } else { + 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, }) } @@ -337,7 +432,7 @@ fn apply_change( content: &mut String, line_starts: &mut Vec, change: &DocumentChange, -) -> Result<()> { +) -> 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) @@ -345,7 +440,7 @@ fn apply_change( if end < start { bail!("LSP change range ends before it starts"); } - let removed_byte_length = end - start; + let removed = content[start..end].to_string(); content.replace_range(start..end, &change.text); let first_removed_line = change.start_line as usize + 1; @@ -358,14 +453,18 @@ fn apply_change( .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_byte_length as isize; + 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(()) + Ok(UndoStep::Edit { + start, + inserted_len: change.text.len(), + removed, + }) } #[cfg(test)] @@ -579,4 +678,113 @@ mod tests { 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/manager.rs b/crates/lsp/src/manager.rs index 42d317edf..638391c1c 100644 --- a/crates/lsp/src/manager.rs +++ b/crates/lsp/src/manager.rs @@ -29,14 +29,15 @@ pub struct LspManager { fn send_document_changes( client: &LspClient, file_path: &str, - pending: &PendingDocumentChanges, + pending: &mut PendingDocumentChanges, ) -> Result<()> { + let uri = manager_support::text_document_identifier(file_path)?.uri; client.text_document_did_change(DidChangeTextDocumentParams { text_document: VersionedTextDocumentIdentifier { - uri: manager_support::text_document_identifier(file_path)?.uri, + uri, version: pending.version, }, - content_changes: pending.changes.clone(), + content_changes: pending.take_changes_for_send(), }) } @@ -1468,7 +1469,9 @@ impl LspManager { self.document_sessions.queue_many(file_path, batches)?; let sessions = self.document_sessions.clone(); let file_path = file_path.to_string(); - tokio::spawn(async move { + // 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| { diff --git a/src/features/editor/lsp/lsp-client.ts b/src/features/editor/lsp/lsp-client.ts index 2c02b36ac..4cb17b4f6 100644 --- a/src/features/editor/lsp/lsp-client.ts +++ b/src/features/editor/lsp/lsp-client.ts @@ -155,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 @@ -172,6 +179,7 @@ export class LspClient { private documentChangeTimers = new Map>(); private documentChangeSendChains = new Map>(); private documentChangeRetries = new Map(); + private documentsNeedingResync = new Set(); private constructor() { this.setupDiagnosticsListener(); @@ -199,27 +207,41 @@ export class LspClient { ); } + /** 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 batches = this.documentChangeQueues.get(filePath); - if (!batches?.length) { + 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); - 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: batches.map( + // 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, @@ -228,23 +250,44 @@ export class LspClient { 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 (this.openDocuments.has(filePath) && retries < 2) { + if (retries < MAX_DOCUMENT_CHANGE_RETRIES) { this.documentChangeRetries.set(filePath, retries + 1); - const queued = this.documentChangeQueues.get(filePath) ?? []; - this.documentChangeQueues.set(filePath, [...batches, ...queued]); - this.documentChangeTimers.set( - filePath, - setTimeout(() => void this.flushDocumentChanges(filePath), 80 * (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); } - logger.error("LSPClient", "LSP incremental document change error:", error); + this.documentChangeTimers.set( + filePath, + setTimeout(() => void this.flushDocumentChanges(filePath), 80 * (retries + 1)), + ); }); this.documentChangeSendChains.set(filePath, send); return send; @@ -258,13 +301,20 @@ export class LspClient { 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 < 3; attempt += 1) { + for (let attempt = 0; attempt <= MAX_DOCUMENT_CHANGE_RETRIES + 1; attempt += 1) { await this.flushDocumentChanges(filePath); - if (!this.documentChangeRetries.has(filePath)) return; - if (!this.documentChangeQueues.has(filePath)) break; + if ( + !this.documentChangeQueues.has(filePath) && + (!this.documentsNeedingResync.has(filePath) || attempt > MAX_DOCUMENT_CHANGE_RETRIES) + ) { + return; + } } - throw new Error(`Could not synchronize pending LSP changes for ${filePath}`); } /** @@ -1642,11 +1692,15 @@ export class LspClient { } } - async notifyDocumentOpen(filePath: string, content: string): Promise { + 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); @@ -1690,23 +1744,8 @@ export class LspClient { }); } - async notifyDocumentChange(filePath: string, content: string, version: number): Promise { - try { - await this.flushDocumentChangesBeforeOperation(filePath); - 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); - } - } - - async notifyDocumentSave(filePath: string, content?: string): Promise { - void content; + /** The saved text, when the server wants it, comes from the backend's synchronized copy. */ + async notifyDocumentSave(filePath: string): Promise { try { await this.flushDocumentChangesBeforeOperation(filePath); await invoke("lsp_document_save", { filePath }); @@ -1743,6 +1782,7 @@ export class LspClient { 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(); diff --git a/src/features/editor/stores/editor-app.store.ts b/src/features/editor/stores/editor-app.store.ts index 9f8310067..babdf8f39 100644 --- a/src/features/editor/stores/editor-app.store.ts +++ b/src/features/editor/stores/editor-app.store.ts @@ -152,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) { diff --git a/src/features/editor/tests/lsp-document-sync.test.ts b/src/features/editor/tests/lsp-document-sync.test.ts index f6bf73e2d..d80778bf1 100644 --- a/src/features/editor/tests/lsp-document-sync.test.ts +++ b/src/features/editor/tests/lsp-document-sync.test.ts @@ -1,6 +1,7 @@ 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"; @@ -51,6 +52,7 @@ describe("LSP incremental document synchronization", () => { documentChangeTimers: Map>; documentChangeSendChains: Map>; documentChangeRetries: Map; + documentsNeedingResync: Set; }; beforeEach(() => { @@ -73,6 +75,26 @@ describe("LSP incremental document synchronization", () => { 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()); @@ -176,4 +198,66 @@ describe("LSP incremental document synchronization", () => { ]); 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" }), + ); + }); }); From d4accbf6b76fae6ebde16de59e7685605b4c8399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C3=96zg=C3=BCl?= Date: Sun, 27 Sep 2026 21:40:49 +0300 Subject: [PATCH 5/5] Ignore superseded highlight requests A tokenizer request replaced by a newer one for the same view was treated as a failure, so its plain fallback was cached for that code and the query bar logged a warning. Superseded requests now return without caching or falling back. --- src/features/editor/hooks/use-tokenizer.ts | 19 +++++++++++--- .../editor/markdown/code-highlight.ts | 25 +++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/features/editor/hooks/use-tokenizer.ts b/src/features/editor/hooks/use-tokenizer.ts index ff0c20165..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"; @@ -337,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); @@ -468,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/markdown/code-highlight.ts b/src/features/editor/markdown/code-highlight.ts index 59483e98b..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 { tokenizerWorkerClient } from "@/features/editor/lib/wasm-parser/tokenizer-worker-client"; +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"; @@ -210,7 +213,8 @@ async function tokenizeForLanguage( })), code.length, ); - } catch { + } catch (error) { + if (error instanceof TokenizerRequestSupersededError) throw error; return null; } } @@ -256,11 +260,18 @@ export async function getCodeHighlightSegments( if (pending) return pending; const tokenRequest = (async () => { - const treeSitterSegments = await tokenizeForLanguage( - code, - languageId, - requestKey ?? `markdown-code:${languageId}:${contentKey}`, - ); + 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