From 8a93d2961bbd1a3550f300c50ac94db8504c2516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 20 Aug 2026 18:57:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(windows):=20=E9=99=8D=E4=BD=8E=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=BE=93=E5=85=A5=E7=83=AD=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=BC=80=E9=94=80=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AF=8F=E9=94=AE?= =?UTF-8?q?=E5=85=A8=E6=96=87=E5=90=8C=E6=AD=A5=E9=80=A0=E6=88=90=E5=8D=A1?= =?UTF-8?q?=E9=A1=BF=20Fixes=20#187?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + docs/architecture/language-tooling.md | 2 +- docs/architecture/lsp-runtime-migration.md | 3 +- rust/lithe-core/src/lsp/interface/client.rs | 86 +++- rust/lithe-core/src/lsp/interface/engine.rs | 391 ++++++++++++------ rust/lithe-core/src/lsp/interface/types.rs | 33 +- rust/lithe-core/src/lsp/tests.rs | 50 +++ rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 19 + scripts/build-windows.ps1 | 31 +- scripts/package-windows.ps1 | 26 +- shared/contracts/rust-core-api.md | 21 +- windows/tauri/.gitignore | 3 + windows/tauri/package.json | 2 +- windows/tauri/src-tauri/src/platform.rs | 13 +- .../editor/components/code-editor.tsx | 87 +++- .../editor/components/monaco-editor.tsx | 145 ++++--- .../engines/monaco/line-endings.test.ts | 5 + .../editor/engines/monaco/line-endings.ts | 3 +- .../editor/engines/monaco/model-content.ts | 4 +- .../engines/monaco/monaco-environment.ts | 37 +- .../editor/hooks/use-lsp-integration.ts | 69 +++- .../editor/inline-edit/use-inline-edit.ts | 17 +- .../src/features/editor/lsp/lsp-client.ts | 16 +- .../lsp/pending-document-changes.test.ts | 20 + .../editor/lsp/pending-document-changes.ts | 31 ++ .../editor/stores/buffer-metadata.test.ts | 46 +++ .../features/editor/stores/buffer-metadata.ts | 59 +++ .../features/editor/stores/buffer.store.ts | 9 +- .../editor/stores/editor-app.store.ts | 10 +- .../features/editor/stores/view.store.test.ts | 34 ++ .../src/features/editor/stores/view.store.ts | 105 +++-- .../src/features/editor/types/editor.types.ts | 1 + .../editor/utils/editor-text-change.test.ts | 21 + .../editor/utils/editor-text-change.ts | 23 ++ .../features/editor/utils/large-file.test.ts | 50 +++ .../src/features/editor/utils/large-file.ts | 52 ++- .../src/features/git/api/git-blame-api.ts | 7 +- .../src/features/git/hooks/use-git-blame.ts | 18 +- .../features/git/stores/git-blame.store.ts | 61 +-- .../footer/footer-editor-status.tsx | 17 +- .../layout/components/main-layout.tsx | 8 +- .../components/workbench-error-boundary.tsx | 57 +++ .../panes/types/pane-content.types.ts | 7 + .../src/features/tabs/components/tab-bar.tsx | 52 +-- .../tabs/utils/tab-chrome-buffer.test.ts | 50 +++ .../features/tabs/utils/tab-chrome-buffer.ts | 55 +++ .../pending-buffer-close-dialog.tsx | 32 ++ .../window/components/title-bar/title-bar.tsx | 48 ++- .../stores/create-workspace-scoped-store.ts | 8 +- windows/tauri/src/i18n/locale.ts | 10 + .../tauri/src/platform/lsp-core-adapter.ts | 61 ++- 52 files changed, 1621 insertions(+), 404 deletions(-) create mode 100644 windows/tauri/src/features/editor/lsp/pending-document-changes.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/pending-document-changes.ts create mode 100644 windows/tauri/src/features/editor/stores/buffer-metadata.test.ts create mode 100644 windows/tauri/src/features/editor/stores/buffer-metadata.ts create mode 100644 windows/tauri/src/features/editor/stores/view.store.test.ts create mode 100644 windows/tauri/src/features/editor/utils/editor-text-change.test.ts create mode 100644 windows/tauri/src/features/editor/utils/editor-text-change.ts create mode 100644 windows/tauri/src/features/editor/utils/large-file.test.ts create mode 100644 windows/tauri/src/features/layout/components/workbench-error-boundary.tsx create mode 100644 windows/tauri/src/features/tabs/utils/tab-chrome-buffer.test.ts create mode 100644 windows/tauri/src/features/tabs/utils/tab-chrome-buffer.ts create mode 100644 windows/tauri/src/features/window/components/pending-buffer-close-dialog.tsx diff --git a/.gitignore b/.gitignore index b49666a61..80057d311 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,14 @@ rust/target/ .idea/ DerivedData/ /windows/build*/ +windows/tauri/src-tauri/target/ +windows/tauri/src-tauri/gen/ +windows/tauri/dist/ dist/ .artifacts/ +*.pdb +*.ilk +*.exp Fixtures/**/target/ *.xcuserstate *.xcuserdata/ diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 6c7da10c0..1e2cc1e67 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -137,7 +137,7 @@ LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provid 当前 transport 是 LSP 标准的 stdio `Content-Length` framing。一个 provider 在一个 workspace root 下复用一个 session;同一 provider 切换到另一个 root 时,manager 会停止旧 session 并创建新 session。 -生产路径由 Rust engine 持有长生命周期 `sessionID -> RuntimeSession` registry。每个 runtime 同时拥有子进程、stdio、frame buffer、文档版本、pending request/deadline、capability 和 diagnostics;Swift 只保存不透明 `sessionID` 与 application-level `operationID`。`syncDocument` 由 Rust 决定发送 version 1 的 `didOpen` 或递增版本的 `didChange`,`pollEvents` 只返回 typed state/feature/diagnostic/result/error 事件。协议 reducer/host 只作为 engine 内部实现与纯函数测试 seam,不属于应用公开命令面。 +生产路径由 Rust engine 持有长生命周期 `sessionID -> RuntimeSession` registry。每个 runtime 同时拥有子进程、stdio、frame buffer、文档版本、pending request/deadline、capability 和 diagnostics;Swift 只保存不透明 `sessionID` 与 application-level `operationID`。`syncDocument` 由 Rust 决定发送 version 1 的 `didOpen` 或递增版本的 `didChange`;当 server 声明 Incremental `textDocumentSync` 且请求携带 range 时发送 range-based `didChange`,否则发送全文。`pollEvents` 立即排空队列;`waitEvents` 在 session 事件 channel 上等待直到有事件或超时后再排空 typed state/feature/diagnostic/result/error 事件。协议 reducer/host 只作为 engine 内部实现与纯函数测试 seam,不属于应用公开命令面。 启动顺序: diff --git a/docs/architecture/lsp-runtime-migration.md b/docs/architecture/lsp-runtime-migration.md index bdda193df..f20aeda21 100644 --- a/docs/architecture/lsp-runtime-migration.md +++ b/docs/architecture/lsp-runtime-migration.md @@ -96,7 +96,8 @@ Primary files: cross-platform process abstraction. - Move stdin/stdout/stderr, partial-frame buffering, and malformed-frame failure handling into the Rust session. -- Add an event queue drained through `lsp.pollEvents`. +- Add an event queue drained through `lsp.pollEvents` or waited on through + `lsp.waitEvents`. - Implement initialize, request, and shutdown deadlines; fail every pending request exactly once on timeout, cancellation, crash, stop, or restart. diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 80ef4b632..3b3bd4372 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1,6 +1,7 @@ //! Pure LSP client-state transitions and JSON-RPC message construction. use super::types::*; +use crate::lsp::{apply_text_edits, ApplyTextEditsRequest, LspTextEdit}; use crate::protocol::{CoreError, ErrorCode}; use serde_json::{json, Value}; @@ -147,14 +148,52 @@ pub fn client_change_document( ) -> Result { validate_uri(&request.uri)?; let mut state = request.state; - let Some(document) = state.open_documents.get_mut(&request.uri) else { + let Some(document) = state.open_documents.get(&request.uri).cloned() else { return Err(CoreError::new( ErrorCode::InvalidRequest, "Cannot change a document that is not open in the LSP client.", )); }; + let incremental = state.text_document_sync == LspTextDocumentSyncKind::Incremental + && !request.content_changes.is_empty() + && request + .content_changes + .iter() + .all(|change| change.range.is_some()); + let next_text = if incremental { + apply_content_changes(&document.text, &request.content_changes)? + } else if !request.content_changes.is_empty() && request.text.is_empty() { + apply_content_changes(&document.text, &request.content_changes)? + } else { + request.text + }; + let content_changes = if incremental { + json!(request + .content_changes + .iter() + .map(|change| { + let range = change.range.expect("incremental changes require a range"); + json!({ + "range": { + "start": { + "line": range.start.line, + "character": range.start.utf16_column + }, + "end": { + "line": range.end.line, + "character": range.end.utf16_column + } + }, + "text": change.text + }) + }) + .collect::>()) + } else { + json!([{ "text": next_text }]) + }; + let mut document = document; document.version += 1; - document.text = request.text; + document.text = next_text; let message = json_rpc_notification( "textDocument/didChange", json!({ @@ -162,14 +201,34 @@ pub fn client_change_document( "uri": document.uri, "version": document.version }, - "contentChanges": [{ - "text": document.text - }] + "contentChanges": content_changes }), )?; + state.open_documents.insert(request.uri, document); Ok(client_response(state, vec![message], Vec::new())) } +fn apply_content_changes( + text: &str, + changes: &[LspDocumentContentChange], +) -> Result { + let mut edits = Vec::new(); + for change in changes { + let Some(range) = change.range else { + return Ok(change.text.clone()); + }; + edits.push(LspTextEdit { + range, + new_text: change.text.clone(), + }); + } + Ok(apply_text_edits(ApplyTextEditsRequest { + text: text.to_string(), + edits, + })? + .text) +} + /// Closes a document and clears diagnostics owned by its URI. pub fn client_close_document( request: ClientCloseDocumentRequest, @@ -338,6 +397,8 @@ pub fn client_apply_server_message( state.server_capabilities = feature_names_from_capabilities( result.get("capabilities").unwrap_or(&Value::Null), ); + state.text_document_sync = + text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); state.initialized = true; responses.push(json_rpc_notification("initialized", json!({}))?); } @@ -346,6 +407,7 @@ pub fn client_apply_server_message( state.initialized = false; state.shutdown_requested = false; state.server_capabilities.clear(); + state.text_document_sync = LspTextDocumentSyncKind::Full; state.open_documents.clear(); state.diagnostics.clear(); state.diagnostic_versions.clear(); @@ -1145,6 +1207,20 @@ fn hex_value(value: u8) -> Option { } } +fn text_document_sync_kind(capabilities: &Value) -> LspTextDocumentSyncKind { + let sync = capabilities.get("textDocumentSync"); + let change = match sync { + Some(Value::Number(value)) => value.as_i64().unwrap_or(1), + Some(Value::Object(value)) => value.get("change").and_then(Value::as_i64).unwrap_or(1), + _ => 1, + }; + match change { + 0 => LspTextDocumentSyncKind::None, + 2 => LspTextDocumentSyncKind::Incremental, + _ => LspTextDocumentSyncKind::Full, + } +} + fn feature_names_from_capabilities(capabilities: &Value) -> Vec { let mut values = Vec::new(); add_capability( diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index aa3aa96be..d93c3e11b 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -7,8 +7,8 @@ use super::{ frame_message, parse_server_messages, ClientApplyServerMessageRequest, ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, - LspClientDiagnostic, LspClientDocument, LspClientState, LspPosition, LspRange, - ParseServerMessagesRequest, + LspClientDiagnostic, LspClientDocument, LspClientState, LspDocumentContentChange, LspPosition, + LspRange, ParseServerMessagesRequest, }; use crate::lsp::languages::jdt::{ adapt_start, initialized_notification, virtual_source_content, virtual_source_resolve_params, @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -96,12 +96,17 @@ pub struct SessionRequest { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -/// Complete document contents to open or update in a session. +/// Complete document contents or incremental edits to open or update in a session. pub struct SyncDocumentRequest { pub session_id: String, pub uri: String, pub language_id: String, + /// Full document text. Required for `didOpen`; optional for incremental `didChange`. + #[serde(default)] pub text: String, + /// Range-based edits used when the server advertised Incremental `textDocumentSync`. + #[serde(default)] + pub content_changes: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -202,6 +207,20 @@ pub struct PollEventsResponse { pub events: Vec, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Request that waits until queued events exist or the timeout elapses. +pub struct WaitEventsRequest { + pub session_id: String, + /// Upper bound for blocking on the session event channel, in milliseconds. + #[serde(default = "default_wait_events_timeout")] + pub timeout_milliseconds: u64, +} + +fn default_wait_events_timeout() -> u64 { + 30_000 +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Sequenced lifecycle, diagnostic, result, or log event from a server session. @@ -329,6 +348,7 @@ struct RuntimeSession { #[cfg(test)] root_uri: String, state: Mutex, + event_signal: Condvar, process: Arc, active: AtomicBool, } @@ -405,6 +425,15 @@ pub fn poll_events(request: SessionRequest) -> Result Result { + Ok(PollEventsResponse { + events: engine() + .session(&request.session_id)? + .wait_events(Duration::from_millis(request.timeout_milliseconds))?, + }) +} + /// Stops a session if necessary and removes all state owned by it. pub fn destroy_server(request: SessionRequest) -> Result<(), CoreError> { engine().destroy(&request.session_id) @@ -516,6 +545,7 @@ impl LspEngine { shutdown_timeout, terminal_event_emitted: false, }), + event_signal: Condvar::new(), process: process.handle, active: AtomicBool::new(true), }); @@ -599,6 +629,7 @@ impl RuntimeSession { state: state.client.clone(), uri: uri.clone(), text: request.text, + content_changes: request.content_changes, })? } else { client_open_document(ClientOpenDocumentRequest { @@ -914,6 +945,40 @@ impl RuntimeSession { Ok(state.events.drain(..).collect()) } + fn wait_events(&self, timeout: Duration) -> Result, CoreError> { + let mut state = self.lock_state()?; + let deadline = Instant::now() + timeout; + loop { + crate::protocol::cancellation::check()?; + if !state.events.is_empty() { + return Ok(state.events.drain(..).collect()); + } + if matches!( + state.lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + return Ok(Vec::new()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(Vec::new()); + } + let (guard, wait_result) = + self.event_signal + .wait_timeout(state, remaining) + .map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server event wait lock was poisoned.", + ) + })?; + state = guard; + if wait_result.timed_out() && state.events.is_empty() { + return Ok(Vec::new()); + } + } + } + #[cfg(test)] fn snapshot(&self) -> Result { let state = self.lock_state()?; @@ -1748,6 +1813,15 @@ fn parse_server_info(message: &Value) -> Option { }) } +fn enqueue_runtime_event( + session: &RuntimeSession, + state: &mut SessionState, + event: LspRuntimeEvent, +) { + state.events.push_back(event); + session.event_signal.notify_all(); +} + fn transition_locked( session: &RuntimeSession, state: &mut SessionState, @@ -1756,25 +1830,29 @@ fn transition_locked( ) { state.lifecycle = lifecycle; let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "stateChanged".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: Some(lifecycle), - operation_id: None, - method: None, - uri: None, - version: None, - diagnostics: None, - result: None, - error, - capabilities: None, - server_info: None, - level: None, - message: None, - detail: None, - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "stateChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: Some(lifecycle), + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); } fn push_request_event( @@ -1786,25 +1864,29 @@ fn push_request_event( error: Option, ) { let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "requestCompleted".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: Some(operation_id.to_string()), - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result, - error, - capabilities: None, - server_info: None, - level: None, - message: None, - detail: None, - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "requestCompleted".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id.to_string()), + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); } fn push_diagnostics_event( @@ -1815,25 +1897,29 @@ fn push_diagnostics_event( diagnostics: Vec, ) { let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "diagnostics".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: None, - method: None, - uri: Some(uri.to_string()), - version, - diagnostics: Some(diagnostics), - result: None, - error: None, - capabilities: None, - server_info: None, - level: None, - message: None, - detail: None, - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "diagnostics".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: Some(uri.to_string()), + version, + diagnostics: Some(diagnostics), + result: None, + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }, + ); } fn push_features_event( @@ -1842,48 +1928,56 @@ fn push_features_event( capabilities: Vec, ) { let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "featuresChanged".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: None, - method: None, - uri: None, - version: None, - diagnostics: None, - result: None, - error: None, - capabilities: Some(capabilities), - server_info: None, - level: None, - message: None, - detail: None, - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "featuresChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: Some(capabilities), + server_info: None, + level: None, + message: None, + detail: None, + }, + ); } fn push_server_info_event(session: &RuntimeSession, state: &mut SessionState, info: LspServerInfo) { let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "serverInfoChanged".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: None, - method: None, - uri: None, - version: None, - diagnostics: None, - result: None, - error: None, - capabilities: None, - server_info: Some(info), - level: None, - message: None, - detail: None, - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "serverInfoChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: Some(info), + level: None, + message: None, + detail: None, + }, + ); } fn push_log_event( @@ -1894,25 +1988,29 @@ fn push_log_event( detail: Option, ) { let sequence = take_sequence(state); - state.events.push_back(LspRuntimeEvent { - kind: "log".to_string(), - sequence, - provider_id: session.provider_id.clone(), - session_id: session.id.clone(), - state: None, - operation_id: None, - method: None, - uri: None, - version: None, - diagnostics: None, - result: None, - error: None, - capabilities: None, - server_info: None, - level: Some(level.to_string()), - message: Some(message.to_string()), - detail: detail.filter(|value| !value.is_empty()), - }); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "log".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: None, + level: Some(level.to_string()), + message: Some(message.to_string()), + detail: detail.filter(|value| !value.is_empty()), + }, + ); } fn take_sequence(state: &mut SessionState) -> u64 { @@ -2146,6 +2244,7 @@ mod tests { uri: uri.to_string(), language_id: "go".to_string(), text: text.to_string(), + content_changes: Vec::new(), }) .expect("syncing a document should succeed"); } @@ -2541,6 +2640,7 @@ mod tests { uri: "file:///workspace/main.go".to_string(), language_id: "go".to_string(), text: "package main".to_string(), + content_changes: Vec::new(), }) .expect_err("a broken pipe must surface to the caller"); assert!(matches!(error.code, ErrorCode::ProcessFailed)); @@ -2925,6 +3025,7 @@ mod tests { uri: uri.to_string(), language_id: "go".to_string(), text: "package main".to_string(), + content_changes: Vec::new(), }) .unwrap(); old_server.send(json!({ @@ -2986,6 +3087,7 @@ mod tests { uri: uri.to_string(), language_id: "go".to_string(), text: "package main".to_string(), + content_changes: Vec::new(), }) .is_err()); } @@ -3110,6 +3212,59 @@ mod tests { assert!(event.error.is_none()); } + #[test] + fn wait_events_returns_queued_events_without_waiting_the_timeout() { + let harness = Harness::start(|_| {}); + let started = Instant::now(); + let events = harness + .session() + .wait_events(Duration::from_secs(2)) + .expect("waiting should succeed"); + assert!( + !events.is_empty(), + "starting a session should enqueue at least one lifecycle event" + ); + assert!( + started.elapsed() < Duration::from_millis(500), + "queued events must not wait out the timeout" + ); + } + + #[test] + fn wait_events_times_out_with_an_empty_queue() { + let mut harness = Harness::ready(); + harness.poll(); + let started = Instant::now(); + let events = harness + .session() + .wait_events(Duration::from_millis(40)) + .expect("waiting should succeed"); + assert!(events.is_empty()); + assert!(started.elapsed() >= Duration::from_millis(30)); + } + + #[test] + fn wait_events_wakes_when_the_session_enqueues_a_lifecycle_event() { + let mut harness = Harness::ready(); + harness.poll(); + let session = harness.session(); + let waiter = thread::spawn({ + let session = Arc::clone(&session); + move || { + session + .wait_events(Duration::from_secs(2)) + .expect("waiting should succeed") + } + }); + thread::sleep(Duration::from_millis(30)); + session.stop().expect("the session should stop"); + let events = waiter.join().expect("the waiter thread should finish"); + assert!( + events.iter().any(|event| event.kind == "stateChanged"), + "stop should wake waiters with a lifecycle event, got {events:?}" + ); + } + #[test] fn java_runtime_is_derived_from_the_start_environment() { let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 493862e8a..30ad32426 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -81,6 +81,30 @@ pub struct LspPositionResponse { pub utf16_column: i64, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// LSP `TextDocumentSyncKind` advertised by the server. +pub enum LspTextDocumentSyncKind { + /// The server does not want document change notifications. + None, + /// The server expects complete document text on every change. + #[default] + Full, + /// The server accepts range-based incremental `didChange` payloads. + Incremental, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One LSP `textDocument/didChange` content change. +pub struct LspDocumentContentChange { + /// Inclusive start / exclusive end range; omitted for a full-document replacement. + #[serde(default)] + pub range: Option, + #[serde(default)] + pub text: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] /// Pure client protocol state carried between JSON command invocations. @@ -94,6 +118,9 @@ pub struct LspClientState { #[serde(default)] pub server_capabilities: Vec, #[serde(default)] + /// Server `TextDocumentSyncKind`, used to choose full or incremental `didChange`. + pub text_document_sync: LspTextDocumentSyncKind, + #[serde(default)] pub open_documents: BTreeMap, #[serde(default)] pub pending_requests: BTreeMap, @@ -110,6 +137,7 @@ impl Default for LspClientState { initialized: false, shutdown_requested: false, server_capabilities: Vec::new(), + text_document_sync: LspTextDocumentSyncKind::Full, open_documents: BTreeMap::new(), pending_requests: BTreeMap::new(), diagnostics: BTreeMap::new(), @@ -189,12 +217,15 @@ pub struct ClientOpenDocumentRequest { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -/// Inputs for replacing a synchronized document's complete contents. +/// Inputs for replacing a synchronized document's contents. pub struct ClientChangeDocumentRequest { #[serde(default)] pub state: LspClientState, pub uri: String, + #[serde(default)] pub text: String, + #[serde(default)] + pub content_changes: Vec, } #[derive(Debug, Clone, Deserialize)] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 7396c18f3..42ef2bf40 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -593,6 +593,7 @@ fn client_core_tracks_documents_and_feature_requests() { state: opened.state, uri: "file:///tmp/project/main.rs".to_string(), text: "fn main() { launch(); }\n".to_string(), + content_changes: Vec::new(), }) .unwrap(); assert_eq!( @@ -632,6 +633,54 @@ fn client_core_tracks_documents_and_feature_requests() { assert_eq!(request_message["params"]["position"]["character"], 12); } +#[test] +fn client_change_document_emits_incremental_ranges_when_the_server_supports_them() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + let mut state = opened.state; + state.text_document_sync = LspTextDocumentSyncKind::Incremental; + let changed = client_change_document(ClientChangeDocumentRequest { + state, + uri: "file:///tmp/project/main.rs".to_string(), + text: String::new(), + content_changes: vec![LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 8, + }, + end: LspPosition { + line: 0, + utf16_column: 8, + }, + }), + text: "launch".to_string(), + }], + }) + .unwrap(); + assert_eq!( + changed + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .text, + "fn main(launch) {}\n" + ); + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + assert_eq!(did_change["method"], "textDocument/didChange"); + assert_eq!(did_change["params"]["contentChanges"][0]["text"], "launch"); + assert_eq!( + did_change["params"]["contentChanges"][0]["range"]["start"]["character"], + 8 + ); +} + #[test] fn client_core_closes_open_documents() { let uri = "file:///tmp/project/main.go"; @@ -728,6 +777,7 @@ fn client_core_ignores_diagnostics_for_unopened_or_stale_document_versions() { state: opened.state, uri: uri.to_string(), text: "fn main() { launch(); }\n".to_string(), + content_changes: Vec::new(), }) .unwrap(); diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 79ba169ca..c81725761 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -105,6 +105,8 @@ pub enum CoreCommand { LspCancelOperation, /// Drains queued session events (`lsp.pollEvents`). LspPollEvents, + /// Waits for queued session events (`lsp.waitEvents`). + LspWaitEvents, /// Stops and removes a server session (`lsp.destroyServer`). LspDestroyServer, /// Discovers Java main classes and run entries (`java.runConfigurations`). @@ -220,6 +222,7 @@ impl CoreCommand { "lsp.request" => Some(Self::LspRequest), "lsp.cancelOperation" => Some(Self::LspCancelOperation), "lsp.pollEvents" => Some(Self::LspPollEvents), + "lsp.waitEvents" => Some(Self::LspWaitEvents), "lsp.destroyServer" => Some(Self::LspDestroyServer), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), @@ -274,6 +277,7 @@ mod tests { "lsp.request", "lsp.cancelOperation", "lsp.pollEvents", + "lsp.waitEvents", "lsp.destroyServer", ] { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index c870f8c76..b76d2ee5a 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -656,6 +656,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspWaitEvents => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP wait-events request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::wait_events) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP wait-events response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspDestroyServer => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1269,6 +1284,10 @@ mod tests { "command": "lsp.pollEvents", "payload": { "sessionId": unknown_session } }), + json!({ + "command": "lsp.waitEvents", + "payload": { "sessionId": unknown_session } + }), json!({ "command": "lsp.destroyServer", "payload": { "sessionId": unknown_session } diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index b427cddd3..905432331 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -17,9 +17,33 @@ if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { throw "Bun is required to build the Windows application." } +function Add-DirectoryToPath([string]$Directory) { + if ([string]::IsNullOrWhiteSpace($Directory)) { return } + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return } + $existing = $env:Path -split ";" | Where-Object { $_ -ne "" } + if ($existing -contains $Directory) { return } + $env:Path = "$Directory;" + $env:Path +} + +# bunx hides rustup/nodejs shims from Tauri's cargo lookup on Windows. +$cargoHome = if (-not [string]::IsNullOrWhiteSpace($env:CARGO_HOME)) { + $env:CARGO_HOME +} else { + Join-Path $env:USERPROFILE ".cargo" +} +Add-DirectoryToPath (Join-Path $cargoHome "bin") +$nodeCommand = Get-Command node -ErrorAction SilentlyContinue +if ($null -ne $nodeCommand) { + Add-DirectoryToPath (Split-Path -Parent $nodeCommand.Source) +} + & rustup target add $RustTarget if ($LASTEXITCODE -ne 0) { throw "Could not install Rust target $RustTarget" } +if ($null -eq (Get-Command cargo -ErrorAction SilentlyContinue)) { + throw "Cargo is required to build the Windows application." +} + & bun install --frozen-lockfile if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed" } @@ -27,7 +51,9 @@ if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation faile if ($LASTEXITCODE -ne 0) { throw "Windows frontend type check failed" } $tauriArgs = @( - "tauri", "build", + "tauri", + "--", + "build", "--no-bundle", "--config", "src-tauri/tauri.windows.conf.json", "--target", $RustTarget @@ -37,7 +63,8 @@ if ($Configuration -eq "Debug") { } else { $tauriArgs += @("--config", "src-tauri/tauri.jdtls.conf.json") } -& bunx @tauriArgs +# Use the workspace @tauri-apps/cli. `bunx tauri` drops cargo from PATH. +& bun run @tauriArgs if ($LASTEXITCODE -ne 0) { throw "Windows Tauri build failed" } Write-Output "Windows Tauri build completed." diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 84df7db15..05f17d4d0 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -64,18 +64,40 @@ if ($RequireUpdaterArtifacts) { $versionOverrides | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $versionConfig Set-Location $windowsApp + +function Add-DirectoryToPath([string]$Directory) { + if ([string]::IsNullOrWhiteSpace($Directory)) { return } + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return } + $existing = $env:Path -split ";" | Where-Object { $_ -ne "" } + if ($existing -contains $Directory) { return } + $env:Path = "$Directory;" + $env:Path +} + +$cargoHome = if (-not [string]::IsNullOrWhiteSpace($env:CARGO_HOME)) { + $env:CARGO_HOME +} else { + Join-Path $env:USERPROFILE ".cargo" +} +Add-DirectoryToPath (Join-Path $cargoHome "bin") +$nodeCommand = Get-Command node -ErrorAction SilentlyContinue +if ($null -ne $nodeCommand) { + Add-DirectoryToPath (Split-Path -Parent $nodeCommand.Source) +} + & bun install --frozen-lockfile if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed" } $tauriArgs = @( - "tauri", "build", + "tauri", + "--", + "build", "--config", "src-tauri/tauri.windows.conf.json", "--config", "src-tauri/tauri.jdtls.conf.json", "--config", $versionConfig, "--bundles", "nsis" ) if ($Configuration -eq "Debug") { $tauriArgs += "--debug" } -& bunx @tauriArgs +& bun run @tauriArgs if ($LASTEXITCODE -ne 0) { throw "Tauri NSIS packaging failed" } $bundleDirectory = Join-Path $windowsApp "src-tauri/target/release/bundle/nsis" diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1cc6fd1ab..d4dd825db 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -87,11 +87,12 @@ stable error code and a user-facing message: | `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | | `lsp.startServer` | Start one Rust-owned process/session and begin initialization | | `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | -| `lsp.syncDocument` | Open or full-text change a Rust-owned document with monotonic versions | +| `lsp.syncDocument` | Open a document or apply a full-text or incremental `didChange` with monotonic versions | | `lsp.closeDocument` | Close a document and clear its diagnostics | | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.cancelOperation` | Cancel one pending semantic operation | | `lsp.pollEvents` | Drain ordered typed lifecycle/feature/diagnostic/result/log events | +| `lsp.waitEvents` | Block until queued events exist or a timeout elapses, then drain them | | `lsp.clearDiagnostics` | Clear every diagnostic owned by a session | | `lsp.snapshot` | Return a diagnostic runtime snapshot for testing and control surfaces | | `lsp.destroyServer` | Remove a terminal session handle from the registry | @@ -321,7 +322,8 @@ then submitted to the Rust-owned runtime. Built-in descriptors are merged by pro [`language-tooling.md`](../../docs/architecture/language-tooling.md) for routing, discovery, lifecycle, and compatibility rules. -The `lsp.*Server`, `lsp.*Document`, `lsp.request`, and `lsp.pollEvents` +The `lsp.*Server`, `lsp.*Document`, `lsp.request`, `lsp.pollEvents`, and +`lsp.waitEvents` commands are the semantic LSP runtime boundary. `lsp.startServer` accepts the provider ID, selected executable/arguments/environment, root URI, working directory, initialization options, optional runtime executable and cache @@ -330,16 +332,23 @@ session's child process, stdin/stdout/stderr, framing buffer, JSON-RPC request IDs, document versions, pending deadlines, capabilities, diagnostics, and graceful/forced termination. -`lsp.syncDocument` accepts `{ sessionId, uri, languageId, text }`; the first -sync emits `didOpen` at version 1 and later syncs emit full-text `didChange` -with increasing versions. `lsp.request` accepts a semantic `operation` plus +`lsp.syncDocument` accepts `{ sessionId, uri, languageId, text?, contentChanges? }`. +The first sync emits `didOpen` at version 1. Later syncs emit `didChange` with +increasing versions. When the server advertised incremental `textDocumentSync` +and `contentChanges` includes LSP ranges, the notification carries those +range-based edits and does not require a full document `text` field. Otherwise +the change is a full-text replacement. `lsp.request` accepts a semantic `operation` plus the operation-specific URI, position, range, diagnostics, item, action, or command fields, and returns `{ operationId }`. Supported operations include completion, hover, definition/declaration/type-definition, references, implementation, rename, formatting, code actions and resolve, execute command, inlay hints, folding ranges, code lens, and provider virtual documents. -`lsp.pollEvents` drains events ordered by per-session `sequence`. Event types +`lsp.pollEvents` drains events ordered by per-session `sequence`. `lsp.waitEvents` +accepts `{ sessionId, timeoutMilliseconds }` and waits on a session event +channel until events are queued or the timeout elapses, then drains the same +typed events. Hosts should use `waitEvents` so idle sessions do not poll. +Event types include `stateChanged`, `featuresChanged`, `diagnostics`, `requestCompleted`, `serverInfoChanged`, and `log`. Every request completes at most once with either `result` or a structured runtime error containing diff --git a/windows/tauri/.gitignore b/windows/tauri/.gitignore index abbd48e3c..c60d8bff2 100644 --- a/windows/tauri/.gitignore +++ b/windows/tauri/.gitignore @@ -8,5 +8,8 @@ dist/ *.log src-tauri/target/ src-tauri/gen/ +*.pdb +*.ilk +*.exp .DS_Store .Thumbs.db diff --git a/windows/tauri/package.json b/windows/tauri/package.json index 93ac9b31f..99735e9b3 100644 --- a/windows/tauri/package.json +++ b/windows/tauri/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "bun ./node_modules/vite-plus/bin/vp dev", - "build": "bunx vp build", + "build": "bun ./node_modules/vite-plus/bin/vp build", "preview": "bunx vp preview", "tauri": "tauri", "desktop:dev": "tauri dev --config src-tauri/tauri.windows.conf.json", diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 209ce64b4..0dbd4da94 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -6,11 +6,16 @@ static REQUEST_ID: AtomicU64 = AtomicU64::new(1); #[tauri::command] pub async fn platform_invoke(command: String, args: Value) -> Result { + let operation_id = args + .get("operationId") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| format!("windows-{}", REQUEST_ID.fetch_add(1, Ordering::Relaxed))); let (core_command, payload) = translate(&command, args)?; - let id = format!("windows-{}", REQUEST_ID.fetch_add(1, Ordering::Relaxed)); let request = json!({ - "id": id, - "operationId": id, + "id": operation_id, + "operationId": operation_id, "timeoutMilliseconds": 30_000, "command": core_command, "payload": payload @@ -53,6 +58,8 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git_status" => "git.status", "git_blame_file" => { move_field(&mut payload, "filePath", "path"); + payload.remove("operationId"); + payload.remove("content"); "git.blame" } "git_log" | "git_branches" => "git.history", diff --git a/windows/tauri/src/features/editor/components/code-editor.tsx b/windows/tauri/src/features/editor/components/code-editor.tsx index 1f2d2d56f..e965fd5a7 100644 --- a/windows/tauri/src/features/editor/components/code-editor.tsx +++ b/windows/tauri/src/features/editor/components/code-editor.tsx @@ -14,6 +14,10 @@ import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { useLspIntegration } from "@/features/editor/hooks/use-lsp-integration"; import { useEditorScroll } from "@/features/editor/hooks/use-scroll"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { + editorBufferSurfacesEqual, + selectEditorBufferSurface, +} from "@/features/editor/stores/buffer-metadata"; import { useEditorSettingsStore } from "@/features/editor/stores/settings.store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; import { useEditorViewStore } from "@/features/editor/stores/view.store"; @@ -157,7 +161,11 @@ const CodeEditor = ({ const activeBufferId = useBufferStore((state) => propBufferId ?? state.activeBufferId); const zoomLevel = useZoomStore.use.editorZoomLevel(); const activeBuffer = useBufferStore( - useCallback((state) => getBufferById(state.buffers, activeBufferId), [activeBufferId]), + useCallback( + (state) => selectEditorBufferSurface(state.buffers, activeBufferId), + [activeBufferId], + ), + editorBufferSurfacesEqual, ); const editorViewKey = paneId && activeBufferId ? `${paneId}:${activeBufferId}` : activeBufferId; const { handleContentChange } = useEditorAppStore.use.actions(); @@ -169,21 +177,51 @@ const CodeEditor = ({ const zoomedFontSize = editorFontSize * zoomLevel; const zoomedLineHeight = calculateLineHeight(zoomedFontSize, editorLineHeight); - // Extract values from active buffer or use defaults - const value = activeBuffer && hasTextContent(activeBuffer) ? activeBuffer.content : ""; - valueRef.current = value; const filePath = activeBuffer?.path || ""; + const needsLiveNotebookContent = isPythonScriptFile(filePath) || isRMarkdownFile(filePath); + const notebookContent = useBufferStore((state) => { + if (!needsLiveNotebookContent) return ""; + const buffer = getBufferById(state.buffers, activeBufferId); + return buffer && hasTextContent(buffer) ? buffer.content : ""; + }); const onChange = activeBuffer ? (onContentChange ?? (isActiveSurface ? handleContentChange : () => {})) : () => {}; + const handleEditorContentChange = useCallback( + ( + content: string, + previousContent?: string, + previousCursorPosition?: Position, + previousSelection?: Range, + options?: EditorContentChangeOptions, + ) => { + valueRef.current = content; + onChange(content, previousContent, previousCursorPosition, previousSelection, options); + }, + [onChange], + ); const isPreviewBuffer = activeBuffer?.isPreview ?? false; const showNotebookEditor = activeBuffer?.type === "editor" && filePath.toLowerCase().endsWith(".ipynb"); + const [forceExpensiveServices, setForceExpensiveServices] = useState(false); + const tooLargeForEditorServices = useEditorViewStore( + (state) => state.tooLargeForEditorServices === true, + ); const enableInteractiveServices = isActiveSurface && !isPreviewBuffer && !readOnly && !showNotebookEditor; - const enableRichEditorServices = enableInteractiveServices; + const enableRichEditorServices = + enableInteractiveServices && (!tooLargeForEditorServices || forceExpensiveServices); const enableCodeLens = enableRichEditorServices && codeLensEnabled; + useLayoutEffect(() => { + const buffer = getBufferById(useBufferStore.getState().buffers, activeBufferId); + valueRef.current = buffer && hasTextContent(buffer) ? buffer.content : ""; + }, [activeBufferId, activeBuffer?.contentRevision]); + + useEffect(() => { + setForceExpensiveServices(false); + }, [activeBufferId]); + const showMarkdownPreview = activeBuffer?.type === "markdownPreview"; const showHtmlPreview = activeBuffer?.type === "htmlPreview"; const showCsvPreview = activeBuffer?.type === "csvPreview"; @@ -297,7 +335,7 @@ const CodeEditor = ({ useLspIntegration({ enabled: enableRichEditorServices, filePath, - value, + contentRevision: activeBuffer?.contentRevision ?? 0, }); // Rename symbol support @@ -305,8 +343,10 @@ const CodeEditor = ({ const pythonScriptCells = useMemo( () => - enableInteractiveServices && isPythonScriptFile(filePath) ? getPythonScriptCells(value) : [], - [enableInteractiveServices, filePath, value], + enableInteractiveServices && isPythonScriptFile(filePath) + ? getPythonScriptCells(notebookContent) + : [], + [enableInteractiveServices, filePath, notebookContent], ); const pythonScriptCellLenses = useMemo( () => @@ -319,8 +359,11 @@ const CodeEditor = ({ [pythonScriptCells, t], ); const rMarkdownChunks = useMemo( - () => (enableInteractiveServices && isRMarkdownFile(filePath) ? getRMarkdownChunks(value) : []), - [enableInteractiveServices, filePath, value], + () => + enableInteractiveServices && isRMarkdownFile(filePath) + ? getRMarkdownChunks(notebookContent) + : [], + [enableInteractiveServices, filePath, notebookContent], ); const rMarkdownChunkLenses = useMemo( () => @@ -387,7 +430,7 @@ const CodeEditor = ({ if (!chunk) return; if (!rMarkdownChunkShouldEvaluate(chunk)) { - onChange(clearRMarkdownChunkOutput(valueRef.current, chunk)); + handleEditorContentChange(clearRMarkdownChunkOutput(valueRef.current, chunk)); toast.success(t("notebook.rChunkSkippedEvalFalse")); return; } @@ -402,7 +445,7 @@ const CodeEditor = ({ const currentChunk = getRMarkdownChunks(currentValue)[chunkIndex] ?? chunk; const semanticResult = applyRMarkdownChunkOptionSemantics(result, currentChunk); if (rMarkdownChunkShouldPersistOutput(currentChunk)) { - onChange( + handleEditorContentChange( updateRMarkdownChunkOutput( currentValue, currentChunk, @@ -410,7 +453,7 @@ const CodeEditor = ({ ), ); } else { - onChange(clearRMarkdownChunkOutput(currentValue, currentChunk)); + handleEditorContentChange(clearRMarkdownChunkOutput(currentValue, currentChunk)); } if (result.timedOut) { @@ -442,7 +485,7 @@ const CodeEditor = ({ return; } }, - [filePath, onChange, pythonScriptCells, rMarkdownChunks], + [filePath, handleEditorContentChange, pythonScriptCells, rMarkdownChunks, t], ); // Keep app-owned overlays aligned with Monaco's scroll position. @@ -522,6 +565,19 @@ const CodeEditor = ({ /> )} + {tooLargeForEditorServices && enableInteractiveServices && !forceExpensiveServices && ( +
+ {t("editor.largeFileServicesDisabled")} + +
+ )} +
diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index bee2eca18..10c972410 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -43,9 +43,15 @@ import { isNativeTextInputTarget } from "@/utils/keyboard/text-input-target"; import { getRelativePath, pathStartsWithRoot } from "@/utils/path-helpers"; import EditorContextMenu from "../context-menu/context-menu"; import { useBufferStore } from "../stores/buffer.store"; +import { + editorBufferSurfacesEqual, + selectEditorBufferSurface, +} from "../stores/buffer-metadata"; import { useEditorStateStore } from "../stores/state.store"; -import type { EditorContentChangeOptions, Position, Range } from "../types/editor.types"; +import type { EditorContentChangeOptions, EditorTextChange, Position, Range } from "../types/editor.types"; import { getBufferById } from "../utils/buffer-index"; +import { applyEditorTextChangesToContent } from "../utils/editor-text-change"; +import { queueLspDocumentChanges } from "../lsp/pending-document-changes"; import { fileOpenBenchmark } from "../utils/file-open-benchmark"; import { isEditorGoToDefinitionModifierClick } from "../utils/go-to-definition-gesture"; import { getLanguageIdFromPath } from "../utils/language-id"; @@ -97,6 +103,7 @@ interface MonacoEditorProps { viewStateKey?: string; isActiveSurface?: boolean; isPreviewMode?: boolean; + enableExpensiveServices?: boolean; readOnly?: boolean; scrollable?: boolean; backgroundLayer?: ReactNode; @@ -126,6 +133,7 @@ export function MonacoEditor({ viewStateKey, isActiveSurface = true, isPreviewMode = false, + enableExpensiveServices = true, readOnly = false, scrollable = true, backgroundLayer, @@ -160,13 +168,23 @@ export function MonacoEditor({ const latestContentChangeRef = useRef(onContentChange); const isActiveSurfaceRef = useRef(isActiveSurface); const activeBufferId = useBufferStore((state) => propBufferId ?? state.activeBufferId); - const activeBuffer = useBufferStore( - useCallback((state) => getBufferById(state.buffers, activeBufferId), [activeBufferId]), + const buffer = useBufferStore( + useCallback( + (state) => selectEditorBufferSurface(state.buffers, activeBufferId), + [activeBufferId], + ), + editorBufferSurfacesEqual, ); - const buffer = activeBuffer && activeBuffer.type === "editor" ? activeBuffer : null; - const content = buffer?.content ?? ""; - const filePath = buffer?.path ?? ""; - const languageId = buffer?.languageOverride ?? getLanguageIdFromPath(filePath); + const editorBuffer = buffer?.type === "editor" ? buffer : null; + const editorBufferId = editorBuffer?.id; + const contentRevision = editorBuffer?.contentRevision ?? 0; + const content = useMemo(() => { + if (!editorBufferId) return ""; + const current = getBufferById(useBufferStore.getState().buffers, editorBufferId); + return current && current.type === "editor" ? (current.content ?? "") : ""; + }, [contentRevision, editorBufferId]); + const filePath = editorBuffer?.path ?? ""; + const languageId = editorBuffer?.languageOverride ?? getLanguageIdFromPath(filePath); const monacoLanguageId = toMonacoLanguageId(languageId); const { fontFamily, @@ -192,6 +210,7 @@ export function MonacoEditor({ const autoCompletion = useSettingsStore((state) => state.settings.autoCompletion); const parameterHints = useSettingsStore((state) => state.settings.parameterHints); const codeLens = useSettingsStore((state) => state.settings.codeLens); + const inlayHints = useSettingsStore((state) => state.settings.inlayHints); const semanticTokens = useSettingsStore((state) => state.settings.semanticTokens); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); @@ -216,8 +235,9 @@ export function MonacoEditor({ setViewportHeight, } = useEditorStateStore.use.actions(); const { getBlameForLine } = useGitBlame( - isActiveSurface && inlineGitBlameEnabled && filePath ? filePath : undefined, - content, + isActiveSurface && enableExpensiveServices && inlineGitBlameEnabled && filePath + ? filePath + : undefined, ); const renderInlineGitBlame = useCallback(() => { @@ -380,12 +400,13 @@ export function MonacoEditor({ const inlineEditState = useInlineEdit({ enabled: isActiveSurface && !readOnly && !isPreviewMode, viewKey: viewStateKey ?? activeBufferId ?? null, - buffer: buffer + buffer: editorBuffer ? { - id: buffer.id, - content: buffer.content, - path: buffer.path, + id: editorBuffer.id, + content, + path: editorBuffer.path, language: languageId ?? "", + getContent: () => previousContentRef.current || content, } : undefined, selection, @@ -537,7 +558,7 @@ export function MonacoEditor({ useLayoutEffect(() => { const container = containerRef.current; - if (!container || !buffer) return; + if (!container || !editorBufferId) return; const fontOptions = { fontFamily, fontSize, lineHeight }; syncMonacoHoverBounds(container); if (filePath && fileOpenBenchmark.has(filePath)) { @@ -584,8 +605,9 @@ export function MonacoEditor({ selectionHighlight: highlightOccurrences, quickSuggestions: autoCompletion, suggestOnTriggerCharacters: autoCompletion, - parameterHints: { enabled: parameterHints }, - codeLens, + parameterHints: { enabled: enableExpensiveServices && parameterHints }, + codeLens: enableExpensiveServices && codeLens, + inlayHints: { enabled: enableExpensiveServices && inlayHints ? "on" : "off" }, theme: defineMonacoTheme(themeId, editorItalicComments), cursorStyle: vimModeEnabled && vimCurrentMode === "normal" ? "block" : editorCursorStyle, cursorBlinking: @@ -593,7 +615,7 @@ export function MonacoEditor({ contextmenu: false, overviewRulerLanes: 0, fixedOverflowWidgets: false, - "semanticHighlighting.enabled": semanticTokens, + "semanticHighlighting.enabled": enableExpensiveServices && semanticTokens, scrollbar: { vertical: scrollable ? "auto" : "hidden", horizontal: scrollable ? "auto" : "hidden", @@ -772,29 +794,32 @@ export function MonacoEditor({ }), editor.onDidChangeModelContent((event) => { if (applyingExternalChangeRef.current) return; - const nextContent = model.getValue(); + const contentChanges: EditorTextChange[] = 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 previousContent = previousContentRef.current; + const nextContent = applyEditorTextChangesToContent(previousContent, contentChanges); const editorState = useEditorStateStore.getState(); previousContentRef.current = nextContent; rememberLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, nextContent); + if (filePath && enableExpensiveServices) { + queueLspDocumentChanges(filePath, contentChanges); + } latestContentChangeRef.current?.( nextContent, previousContent, editorState.cursorPosition, editorState.selection, - event.changes.length === 1 - ? { - contentChange: { - rangeOffset: event.changes[0].rangeOffset, - rangeLength: event.changes[0].rangeLength, - text: event.changes[0].text, - startLine: event.changes[0].range.startLineNumber - 1, - startColumn: event.changes[0].range.startColumn - 1, - endLine: event.changes[0].range.endLineNumber - 1, - endColumn: event.changes[0].range.endColumn - 1, - }, - } - : undefined, + { + contentChange: contentChanges[0], + contentChanges, + }, ); syncCursorAndSelection(); }), @@ -907,15 +932,35 @@ export function MonacoEditor({ cancelAnimationFrame(gitBlameRenderFrameRef.current); gitBlameRenderFrameRef.current = null; } - gitBlameWidgetRef.current?.dispose(); + try { + gitBlameWidgetRef.current?.dispose(); + } catch (error) { + console.error("Failed to dispose inline Git blame widget:", error); + } gitBlameWidgetRef.current = null; renderedGitBlameKeyRef.current = null; mouseSelectingRef.current = false; createdEditorDisposable.dispose(); + try { + vimAdapterRef.current?.dispose(); + } catch (error) { + console.error("Failed to dispose Monaco Vim adapter:", error); + } + vimAdapterRef.current = null; + vimStatusRef.current?.remove(); + vimStatusRef.current = null; if (editorRef.current === editor) editorRef.current = null; if (modelRef.current === model) modelRef.current = null; - editor.dispose(); - acquiredModel.release(); + try { + editor.dispose(); + } catch (error) { + console.error("Failed to dispose Monaco editor:", error); + } + try { + acquiredModel.release(); + } catch (error) { + console.error("Failed to release Monaco model:", error); + } }; }, [ activeBufferId, @@ -928,6 +973,8 @@ export function MonacoEditor({ editorScrollBeyondLastLine, editorSmoothScrolling, editorStickyScroll, + enableExpensiveServices, + inlayHints, setContextMenuPosition, filePath, fontFamily, @@ -956,6 +1003,7 @@ export function MonacoEditor({ themeId, viewStateKey, wordWrap, + editorBufferId, ]); useLayoutEffect(() => { @@ -1164,15 +1212,11 @@ export function MonacoEditor({ useEffect(() => { const editor = editorRef.current; const model = modelRef.current; - if (!editor || !model) return; + if (!editorBufferId || !editor || !model) return; - const modelValue = model.getValue(); - if (monacoModelMatchesContent(modelValue, content)) { + const previousContent = previousContentRef.current; + if (previousContent === content || monacoModelMatchesContent(previousContent, content)) { consumeLocalContentSnapshot(pendingLocalContentSnapshotsRef.current, content); - applyingExternalChangeRef.current = true; - applyMonacoModelContent(model, content); - applyingExternalChangeRef.current = false; - previousContentRef.current = content; return; } @@ -1187,7 +1231,7 @@ export function MonacoEditor({ if (selection) editor.setSelection(selection); previousContentRef.current = content; applyingExternalChangeRef.current = false; - }, [content]); + }, [content, editorBufferId]); useEffect(() => { if (!isActiveSurface || readOnly || isPreviewMode) return; @@ -1276,12 +1320,13 @@ export function MonacoEditor({ selectionHighlight: highlightOccurrences, quickSuggestions: autoCompletion, suggestOnTriggerCharacters: autoCompletion, - parameterHints: { enabled: parameterHints }, - codeLens, + parameterHints: { enabled: enableExpensiveServices && parameterHints }, + codeLens: enableExpensiveServices && codeLens, + inlayHints: { enabled: enableExpensiveServices && inlayHints ? "on" : "off" }, cursorStyle: vimModeEnabled && vimCurrentMode === "normal" ? "block" : editorCursorStyle, cursorBlinking: vimModeEnabled && vimCurrentMode === "normal" ? "solid" : editorCursorBlinking, - "semanticHighlighting.enabled": semanticTokens, + "semanticHighlighting.enabled": enableExpensiveServices && semanticTokens, scrollbar: { vertical: scrollable ? "auto" : "hidden", horizontal: scrollable ? "auto" : "hidden", @@ -1311,9 +1356,11 @@ export function MonacoEditor({ editorScrollBeyondLastLine, editorSmoothScrolling, editorStickyScroll, + enableExpensiveServices, fontFamily, fontSize, highlightOccurrences, + inlayHints, isPreviewMode, lineHeight, lineNumbers, @@ -1366,7 +1413,11 @@ export function MonacoEditor({ setMode("normal"); return () => { - adapter.dispose(); + try { + adapter.dispose(); + } catch (error) { + console.error("Failed to dispose Monaco Vim adapter:", error); + } if (vimAdapterRef.current === adapter) vimAdapterRef.current = null; statusNode.remove(); if (vimStatusRef.current === statusNode) vimStatusRef.current = null; @@ -1473,8 +1524,6 @@ export function MonacoEditor({ } }, [activeBufferId, isActiveSurface, viewStateKey]); - if (!buffer) return null; - const shellStyle = { "--lithe-monaco-font-family": fontFamily, "--lithe-monaco-font-size": `${fontSize}px`, diff --git a/windows/tauri/src/features/editor/engines/monaco/line-endings.test.ts b/windows/tauri/src/features/editor/engines/monaco/line-endings.test.ts index edb98de3c..7998c033a 100644 --- a/windows/tauri/src/features/editor/engines/monaco/line-endings.test.ts +++ b/windows/tauri/src/features/editor/engines/monaco/line-endings.test.ts @@ -24,4 +24,9 @@ describe("monaco line endings", () => { expect(documentUsesCrlf(content)).toBe(false); expect(toMonacoModelValue(content)).toBe(content); }); + + test("does not throw when content is missing during editor teardown", () => { + expect(documentUsesCrlf(undefined as unknown as string)).toBe(false); + expect(toMonacoModelValue(undefined as unknown as string)).toBe(""); + }); }); diff --git a/windows/tauri/src/features/editor/engines/monaco/line-endings.ts b/windows/tauri/src/features/editor/engines/monaco/line-endings.ts index 99bcce1b1..7d20cdba7 100644 --- a/windows/tauri/src/features/editor/engines/monaco/line-endings.ts +++ b/windows/tauri/src/features/editor/engines/monaco/line-endings.ts @@ -4,10 +4,11 @@ * mouse hit-testing cannot land after the last visible character. */ export function documentUsesCrlf(content: string): boolean { - return content.includes("\r\n"); + return typeof content === "string" && content.includes("\r\n"); } export function toMonacoModelValue(content: string): string { + if (typeof content !== "string") return ""; if (!content.includes("\r")) return content; return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } diff --git a/windows/tauri/src/features/editor/engines/monaco/model-content.ts b/windows/tauri/src/features/editor/engines/monaco/model-content.ts index d946fd263..25cb4c4a6 100644 --- a/windows/tauri/src/features/editor/engines/monaco/model-content.ts +++ b/windows/tauri/src/features/editor/engines/monaco/model-content.ts @@ -5,7 +5,9 @@ import { documentUsesCrlf, toMonacoModelValue } from "./line-endings"; export function applyMonacoModelContent(model: Monaco.editor.ITextModel, content: string): void { const value = toMonacoModelValue(content); const wantsCrlf = documentUsesCrlf(content); - if (toMonacoModelValue(model.getValue()) !== value) { + if (model.getValueLength() !== value.length) { + model.setValue(value); + } else if (toMonacoModelValue(model.getValue()) !== value) { model.setValue(value); } if ((model.getEOL() === "\r\n") !== wantsCrlf) { diff --git a/windows/tauri/src/features/editor/engines/monaco/monaco-environment.ts b/windows/tauri/src/features/editor/engines/monaco/monaco-environment.ts index 6a934832a..24e2d902e 100644 --- a/windows/tauri/src/features/editor/engines/monaco/monaco-environment.ts +++ b/windows/tauri/src/features/editor/engines/monaco/monaco-environment.ts @@ -1,9 +1,4 @@ import { editor as monacoEditor } from "monaco-editor"; -import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; -import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; -import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; -import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; -import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import { openExternalBrowserUrl, resolveExternalBrowserUrl, @@ -45,13 +40,35 @@ if (typeof window !== "undefined") { window.MonacoEnvironment = { getWorker: (_workerId, label) => { - if (label === "json") return new JsonWorker(); - if (label === "css" || label === "scss" || label === "less") return new CssWorker(); + // Construct workers on demand so the TypeScript worker chunk is not + // fetched until a JS/TS file actually needs it. + if (label === "json") { + return new Worker( + new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url), + { type: "module" }, + ); + } + if (label === "css" || label === "scss" || label === "less") { + return new Worker( + new URL("monaco-editor/esm/vs/language/css/css.worker.js", import.meta.url), + { type: "module" }, + ); + } if (label === "html" || label === "handlebars" || label === "razor") { - return new HtmlWorker(); + return new Worker( + new URL("monaco-editor/esm/vs/language/html/html.worker.js", import.meta.url), + { type: "module" }, + ); + } + if (label === "typescript" || label === "javascript") { + return new Worker( + new URL("monaco-editor/esm/vs/language/typescript/ts.worker.js", import.meta.url), + { type: "module" }, + ); } - if (label === "typescript" || label === "javascript") return new TsWorker(); - return new EditorWorker(); + return new Worker(new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), { + type: "module", + }); }, }; } diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 1230072e4..3f968e3f1 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -3,6 +3,11 @@ import { useExtensionStore } from "@/extensions/registry/extension-store"; import { deferUntilAfterNextPaint } from "@/features/editor/lsp/deferred-lsp-work"; import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { LspClient } from "@/features/editor/lsp/lsp-client"; +import { + hasLspDocumentChanges, + subscribeLspDocumentChanges, + takeLspDocumentChanges, +} from "@/features/editor/lsp/pending-document-changes"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getSourceEditorBufferByPath } from "@/features/editor/utils/buffer-index"; import { logger } from "@/features/editor/utils/logger"; @@ -12,15 +17,19 @@ import { getDirName } from "@/utils/path-helpers"; interface UseLspIntegrationOptions { enabled?: boolean; filePath: string | undefined; - value: string; + contentRevision?: number; } const DOCUMENT_CHANGE_DEBOUNCE_MS = 75; +function documentTextForPath(filePath: string): string { + return getSourceEditorBufferByPath(useBufferStore.getState().buffers, filePath)?.content ?? ""; +} + export const useLspIntegration = ({ enabled = true, filePath, - value, + contentRevision = 0, }: UseLspIntegrationOptions) => { const lspClient = useMemo(() => LspClient.getInstance(), []); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); @@ -30,15 +39,11 @@ export const useLspIntegration = ({ () => isEditorLspSupported(activeFilePath), [activeFilePath, installedExtensions], ); - const documentChangeTimerRef = useRef(undefined); + const documentChangeTimerRef = useRef | undefined>(undefined); const documentVersionsRef = useRef>(new Map()); - const latestValueRef = useRef(value); + const lastSyncedRevisionRef = useRef(contentRevision); const openedDocumentsRef = useRef>(new Set()); - useEffect(() => { - latestValueRef.current = value; - }, [value]); - useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; @@ -78,7 +83,8 @@ export const useLspIntegration = ({ const started = await lspClient.startForFile(filePath, workspacePath); if (!started) return; - await lspClient.notifyDocumentOpen(filePath, latestValueRef.current); + const text = documentTextForPath(filePath); + await lspClient.notifyDocumentOpen(filePath, text); openedDocumentsRef.current.add(filePath); logger.debug("LspIntegration", `LSP started and document opened for ${filePath}`); } catch (error) { @@ -98,29 +104,56 @@ export const useLspIntegration = ({ useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; - if (!openedDocumentsRef.current.has(filePath)) return; - if (documentChangeTimerRef.current) { - clearTimeout(documentChangeTimerRef.current); - } - - documentChangeTimerRef.current = setTimeout(() => { + const flushDocumentChange = (preferIncremental: boolean) => { if (!openedDocumentsRef.current.has(filePath)) return; + const contentChanges = takeLspDocumentChanges(filePath); const currentVersion = documentVersionsRef.current.get(filePath) || 1; const newVersion = currentVersion + 1; documentVersionsRef.current.set(filePath, newVersion); + lastSyncedRevisionRef.current = contentRevision; + + const notify = + preferIncremental && contentChanges.length > 0 + ? lspClient.notifyDocumentChange(filePath, undefined, newVersion, contentChanges) + : lspClient.notifyDocumentChange(filePath, documentTextForPath(filePath), newVersion); - lspClient.notifyDocumentChange(filePath, value, newVersion).catch((error) => { + notify.catch((error) => { console.error("LSP document change error:", error); }); - }, DOCUMENT_CHANGE_DEBOUNCE_MS); + }; + + const scheduleFlush = (preferIncremental: boolean) => { + if (documentChangeTimerRef.current) { + clearTimeout(documentChangeTimerRef.current); + } + documentChangeTimerRef.current = setTimeout(() => { + documentChangeTimerRef.current = undefined; + flushDocumentChange(preferIncremental); + }, DOCUMENT_CHANGE_DEBOUNCE_MS); + }; + + const unsubscribe = subscribeLspDocumentChanges((changedPath) => { + if (changedPath !== filePath) return; + scheduleFlush(true); + }); + + if (hasLspDocumentChanges(filePath)) { + scheduleFlush(true); + } else if ( + openedDocumentsRef.current.has(filePath) && + contentRevision !== lastSyncedRevisionRef.current + ) { + scheduleFlush(false); + } return () => { + unsubscribe(); if (documentChangeTimerRef.current) { clearTimeout(documentChangeTimerRef.current); documentChangeTimerRef.current = undefined; } }; - }, [enabled, filePath, isLspSupported, lspClient, value]); + }, [contentRevision, enabled, filePath, isLspSupported, lspClient]); }; diff --git a/windows/tauri/src/features/editor/inline-edit/use-inline-edit.ts b/windows/tauri/src/features/editor/inline-edit/use-inline-edit.ts index 3597ec1cb..c1f507be0 100644 --- a/windows/tauri/src/features/editor/inline-edit/use-inline-edit.ts +++ b/windows/tauri/src/features/editor/inline-edit/use-inline-edit.ts @@ -44,7 +44,13 @@ interface UseInlineEditOptions { enabled?: boolean; viewKey?: string | null; inputRef?: React.RefObject; - buffer: { id: string; content: string; path: string; language: string } | undefined; + buffer: { + id: string; + content: string; + path: string; + language: string; + getContent?: () => string; + } | undefined; selection: Range | undefined; fontSize: number; fontFamily: string; @@ -353,9 +359,10 @@ export function useInlineEdit({ return; } + const documentContent = buffer.getContent?.() ?? buffer.content; const startOffset = targetRange.start.offset; const endOffset = targetRange.end.offset; - const selectedText = buffer.content.slice(startOffset, endOffset); + const selectedText = documentContent.slice(startOffset, endOffset); const provider = getProviderById(aiProviderId); @@ -406,8 +413,8 @@ export function useInlineEdit({ return; } - const beforeSelection = buffer.content.slice(Math.max(0, startOffset - 12000), startOffset); - const afterSelection = buffer.content.slice(endOffset, endOffset + 12000); + const beforeSelection = documentContent.slice(Math.max(0, startOffset - 12000), startOffset); + const afterSelection = documentContent.slice(endOffset, endOffset + 12000); setInlineEditError(null); setIsInlineEditRunning(true); @@ -432,7 +439,7 @@ export function useInlineEdit({ return; } - const newContent = `${buffer.content.slice(0, startOffset)}${editedText}${buffer.content.slice( + const newContent = `${documentContent.slice(0, startOffset)}${editedText}${documentContent.slice( endOffset, )}`; const newCursorOffset = startOffset + editedText.length; diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 4df22d423..5d3d71daa 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -1258,7 +1258,20 @@ export class LspClient { } } - async notifyDocumentChange(filePath: string, content: string, version: number): Promise { + async notifyDocumentChange( + filePath: string, + content: string | undefined, + version: number, + contentChanges?: Array<{ + rangeOffset: number; + rangeLength: number; + text: string; + startLine?: number; + startColumn?: number; + endLine?: number; + endColumn?: number; + }>, + ): Promise { try { this.openDocuments.add(filePath); this.documentVersions.set(filePath, version); @@ -1266,6 +1279,7 @@ export class LspClient { filePath, content, version, + contentChanges, }); } catch (error) { logger.error("LSPClient", "LSP document change error:", error); diff --git a/windows/tauri/src/features/editor/lsp/pending-document-changes.test.ts b/windows/tauri/src/features/editor/lsp/pending-document-changes.test.ts new file mode 100644 index 000000000..e72cb019b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/pending-document-changes.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { + hasLspDocumentChanges, + queueLspDocumentChanges, + takeLspDocumentChanges, +} from "./pending-document-changes"; + +describe("pending LSP document changes", () => { + test("coalesces range changes until the debounce flush", () => { + queueLspDocumentChanges("src/main.ts", [ + { rangeOffset: 0, rangeLength: 0, text: "a", startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 }, + ]); + queueLspDocumentChanges("src/main.ts", [ + { rangeOffset: 1, rangeLength: 0, text: "b", startLine: 0, startColumn: 1, endLine: 0, endColumn: 1 }, + ]); + expect(hasLspDocumentChanges("src/main.ts")).toBe(true); + expect(takeLspDocumentChanges("src/main.ts")).toHaveLength(2); + expect(hasLspDocumentChanges("src/main.ts")).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/pending-document-changes.ts b/windows/tauri/src/features/editor/lsp/pending-document-changes.ts new file mode 100644 index 000000000..18ec1688b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/pending-document-changes.ts @@ -0,0 +1,31 @@ +import type { EditorTextChange } from "../types/editor.types"; + +type DocumentChangeListener = (filePath: string) => void; + +const pendingChanges = new Map(); +const listeners = new Set(); + +export function queueLspDocumentChanges(filePath: string, changes: readonly EditorTextChange[]): void { + if (!filePath || changes.length === 0) return; + const queued = pendingChanges.get(filePath) ?? []; + queued.push(...changes); + pendingChanges.set(filePath, queued); + for (const listener of listeners) listener(filePath); +} + +export function takeLspDocumentChanges(filePath: string): EditorTextChange[] { + const queued = pendingChanges.get(filePath) ?? []; + pendingChanges.delete(filePath); + return queued; +} + +export function hasLspDocumentChanges(filePath: string): boolean { + return (pendingChanges.get(filePath)?.length ?? 0) > 0; +} + +export function subscribeLspDocumentChanges(listener: DocumentChangeListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/windows/tauri/src/features/editor/stores/buffer-metadata.test.ts b/windows/tauri/src/features/editor/stores/buffer-metadata.test.ts new file mode 100644 index 000000000..181537231 --- /dev/null +++ b/windows/tauri/src/features/editor/stores/buffer-metadata.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { + editorBufferSurfacesEqual, + getEditorBufferSurface, +} from "./buffer-metadata"; + +function editorBuffer(overrides: Partial = {}): EditorContent { + return { + id: "buffer-1", + type: "editor", + path: "src/main.ts", + name: "main.ts", + isPinned: false, + isPreview: false, + isActive: true, + content: "const value = 1;", + savedContent: "const value = 1;", + isDirty: false, + isVirtual: false, + tokens: [], + contentRevision: 0, + ...overrides, + }; +} + +describe("editor buffer surface", () => { + test("omits document text so typing does not change the selected surface", () => { + const before = getEditorBufferSurface(editorBuffer({ content: "a" })); + const after = getEditorBufferSurface(editorBuffer({ content: "ab", isDirty: true })); + expect(editorBufferSurfacesEqual(before, after)).toBe(true); + }); + + test("treats external contentRevision bumps as a new surface", () => { + const before = getEditorBufferSurface(editorBuffer({ contentRevision: 1 })); + const after = getEditorBufferSurface( + editorBuffer({ content: "from disk", contentRevision: 2 }), + ); + expect(editorBufferSurfacesEqual(before, after)).toBe(false); + }); + + test("treats a missing buffer as a null surface without throwing", () => { + expect(getEditorBufferSurface(null)).toBeNull(); + expect(editorBufferSurfacesEqual(null, getEditorBufferSurface(editorBuffer()))).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/editor/stores/buffer-metadata.ts b/windows/tauri/src/features/editor/stores/buffer-metadata.ts new file mode 100644 index 000000000..6fee507b7 --- /dev/null +++ b/windows/tauri/src/features/editor/stores/buffer-metadata.ts @@ -0,0 +1,59 @@ +import type { PaneContent } from "@/features/panes/types/pane-content.types"; +import { getBufferById } from "@/features/editor/utils/buffer-index"; + +export interface EditorBufferSurface { + id: string; + path: string; + type: PaneContent["type"]; + isPreview: boolean; + isVirtual: boolean; + languageOverride?: string; + contentRevision: number; +} + +export function getBufferContentRevision(buffer: PaneContent | null | undefined): number { + if (!buffer) return 0; + if (buffer.type === "editor" || buffer.type === "diff") { + return buffer.contentRevision ?? 0; + } + return 0; +} + +export function getEditorBufferSurface( + buffer: PaneContent | null | undefined, +): EditorBufferSurface | null { + if (!buffer) return null; + return { + id: buffer.id, + path: buffer.path, + type: buffer.type, + isPreview: buffer.isPreview, + isVirtual: buffer.type === "editor" ? buffer.isVirtual : false, + languageOverride: buffer.type === "editor" ? buffer.languageOverride : undefined, + contentRevision: getBufferContentRevision(buffer), + }; +} + +export function selectEditorBufferSurface( + buffers: readonly PaneContent[], + bufferId: string | null | undefined, +): EditorBufferSurface | null { + return getEditorBufferSurface(getBufferById(buffers, bufferId)); +} + +export function editorBufferSurfacesEqual( + left: EditorBufferSurface | null, + right: EditorBufferSurface | null, +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.id === right.id && + left.path === right.path && + left.type === right.type && + left.isPreview === right.isPreview && + left.isVirtual === right.isVirtual && + left.languageOverride === right.languageOverride && + left.contentRevision === right.contentRevision + ); +} diff --git a/windows/tauri/src/features/editor/stores/buffer.store.ts b/windows/tauri/src/features/editor/stores/buffer.store.ts index 2b50ee13d..cafd5e1ab 100644 --- a/windows/tauri/src/features/editor/stores/buffer.store.ts +++ b/windows/tauri/src/features/editor/stores/buffer.store.ts @@ -1,5 +1,4 @@ import { invoke } from "@/platform/tauri-core"; -import isEqual from "fast-deep-equal"; import { immer } from "zustand/middleware/immer"; import { createStore } from "zustand/vanilla"; import type { DatabaseType } from "@/features/database/types/provider.types"; @@ -189,6 +188,7 @@ interface BufferActions { content: string, markDirty?: boolean, diffData?: GitDiff | MultiFileDiff, + options?: { local?: boolean }, ) => void; updateBufferTokens: (bufferId: string, tokens: TokenEntry[]) => void; updateBufferLanguage: (bufferId: string, language: string) => void; @@ -1403,6 +1403,7 @@ const createBufferStore = (workspaceId: string) => { content: string, markDirty = true, diffData?: GitDiff | MultiFileDiff, + options?: { local?: boolean }, ) => { const buffer = getBufferById(get().buffers, bufferId); if (!buffer) return; @@ -1413,11 +1414,15 @@ const createBufferStore = (workspaceId: string) => { if (buffer.content === content && !diffData) return; let promotedPreviewBufferId: string | null = null; + const bumpContentRevision = options?.local !== true; set((state) => { const buf = state.buffers.find((b) => b.id === bufferId); if (!buf || !isEditableContent(buf)) return; buf.content = content; + if (bumpContentRevision) { + buf.contentRevision = (buf.contentRevision ?? 0) + 1; + } if (diffData && buf.type === "diff") { buf.diffData = diffData; } @@ -1875,7 +1880,7 @@ const createBufferStore = (workspaceId: string) => { }; export const useBufferStore = createSelectors( - createWorkspaceScopedStore("editor-buffer", createBufferStore, isEqual), + createWorkspaceScopedStore("editor-buffer", createBufferStore), ); export { clearQueuedWorkspaceSessionSave }; diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.ts b/windows/tauri/src/features/editor/stores/editor-app.store.ts index 5a9efa379..1d7c2867a 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.ts @@ -256,12 +256,12 @@ export const useEditorAppStore = createSelectors( if (!activeBuffer || !isEditorContent(activeBuffer)) return; const collaborationNoteTarget = parseCollaborationNoteBufferPath(activeBuffer.path); - if (!contentAlreadyApplied && activeBufferId && options?.contentChange) { + if (!contentAlreadyApplied && activeBufferId && (options?.contentChanges?.length || options?.contentChange)) { queueEditorViewContentChange( activeBufferId, activeBuffer.content, content, - options.contentChange, + options.contentChanges ?? (options.contentChange ? [options.contentChange] : []), ); } @@ -282,16 +282,16 @@ export const useEditorAppStore = createSelectors( if (isRemoteFile) { if (!contentAlreadyApplied) { - updateBufferContent(activeBuffer.id, content, false); + updateBufferContent(activeBuffer.id, content, false, undefined, { local: true }); } } else if (collaborationNoteTarget) { if (!contentAlreadyApplied) { - updateBufferContent(activeBuffer.id, content, true); + updateBufferContent(activeBuffer.id, content, true, undefined, { local: true }); } markBufferDirty(activeBuffer.id, content !== activeBuffer.savedContent); } else { if (!contentAlreadyApplied) { - updateBufferContent(activeBuffer.id, content, true); + updateBufferContent(activeBuffer.id, content, true, undefined, { local: true }); } if (!activeBuffer.isVirtual && settings.autoSave) { diff --git a/windows/tauri/src/features/editor/stores/view.store.test.ts b/windows/tauri/src/features/editor/stores/view.store.test.ts new file mode 100644 index 000000000..8a6b77bce --- /dev/null +++ b/windows/tauri/src/features/editor/stores/view.store.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { applyEditorTextChangeToLines } from "../stores/view.store"; + +describe("applyEditorTextChangeToLines", () => { + test("inserts into one line without copying the untouched prefix and suffix via spreads of new arrays beyond splice", () => { + const lines = ["alpha", "bravo", "charlie"]; + const result = applyEditorTextChangeToLines(lines, { + rangeOffset: 6, + rangeLength: 0, + text: "X", + startLine: 1, + startColumn: 0, + endLine: 1, + endColumn: 0, + }); + expect(result).toEqual(["alpha", "Xbravo", "charlie"]); + expect(result).toBe(lines); + }); + + test("splits a line when a newline is inserted", () => { + const lines = ["hello world"]; + expect( + applyEditorTextChangeToLines(lines, { + rangeOffset: 5, + rangeLength: 0, + text: "\n", + startLine: 0, + startColumn: 5, + endLine: 0, + endColumn: 5, + }), + ).toEqual(["hello", " world"]); + }); +}); diff --git a/windows/tauri/src/features/editor/stores/view.store.ts b/windows/tauri/src/features/editor/stores/view.store.ts index bec3bf270..5446f9127 100644 --- a/windows/tauri/src/features/editor/stores/view.store.ts +++ b/windows/tauri/src/features/editor/stores/view.store.ts @@ -3,13 +3,15 @@ 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 { createSparseLineArray, applyEditorTextChangeToLargeEditorModeInfo, applyIncrementalLargeEditorModeInfo, getLargeEditorModeInfo, type LargeEditorModeInfo } from "../utils/large-file"; +import { sortEditorTextChangesForOriginalDocument } from "../utils/editor-text-change"; import { useBufferStore } from "./buffer.store"; interface EditorViewState { // Computed views of the active buffer lines: string[]; lineCount: number; + tooLargeForEditorServices: boolean; // Actions actions: { @@ -25,6 +27,7 @@ export const useEditorViewStore = createSelectors( // These will be computed from the active buffer lines: [""], lineCount: 1, + tooLargeForEditorServices: false, actions: { getLines: () => { @@ -50,6 +53,7 @@ let previousActiveBufferSnapshot: { id: string; content: string; lines: string[]; + largeEditorInfo: LargeEditorModeInfo; } | null = null; const INCREMENTAL_LINE_EDIT_THRESHOLD = 1000; @@ -142,11 +146,8 @@ export function applyIncrementalLineEdit( `${insertedLines[insertedLines.length - 1]}${lineSuffix}`, ]; - return [ - ...previousLines.slice(0, start.line), - ...replacement, - ...previousLines.slice(end.line + 1), - ]; + previousLines.splice(start.line, end.line - start.line + 1, ...replacement); + return previousLines; } export function applyEditorTextChangeToLines( @@ -192,17 +193,14 @@ export function applyEditorTextChangeToLines( `${insertedLines[insertedLines.length - 1]}${lineSuffix}`, ]; - return [ - ...previousLines.slice(0, startLine), - ...replacement, - ...previousLines.slice(endLine + 1), - ]; + previousLines.splice(startLine, endLine - startLine + 1, ...replacement); + return previousLines; } interface PendingEditorViewContentChange { previousContent: string; nextContent: string; - change: EditorTextChange; + changes: EditorTextChange[]; } const pendingEditorViewContentChanges = new Map(); @@ -211,12 +209,13 @@ export function queueEditorViewContentChange( bufferId: string, previousContent: string, nextContent: string, - change: EditorTextChange, + change: EditorTextChange | EditorTextChange[], ): void { + const changes = Array.isArray(change) ? change : [change]; pendingEditorViewContentChanges.set(bufferId, { previousContent, nextContent, - change, + changes, }); } @@ -224,62 +223,108 @@ export function queueEditorViewContentChange( useBufferStore.subscribe((state) => { const activeBuffer = state.actions.getActiveBuffer(); if (activeBuffer && isEditorContent(activeBuffer)) { + const bufferContent = typeof activeBuffer.content === "string" ? activeBuffer.content : ""; const previousSnapshot = previousActiveBufferSnapshot; if ( previousSnapshot && previousSnapshot.id === activeBuffer.id && - previousSnapshot.content === activeBuffer.content + previousSnapshot.content === bufferContent ) { return; } - const largeEditorInfo = getLargeEditorModeInfo(activeBuffer.content); + const pendingContentChange = pendingEditorViewContentChanges.get(activeBuffer.id); + pendingEditorViewContentChanges.delete(activeBuffer.id); + const canApplyQueuedChange = + previousSnapshot?.id === activeBuffer.id && + pendingContentChange?.previousContent === previousSnapshot.content && + pendingContentChange.nextContent === bufferContent; + + let largeEditorInfo: LargeEditorModeInfo | null = null; + if (canApplyQueuedChange && previousSnapshot && pendingContentChange) { + largeEditorInfo = previousSnapshot.largeEditorInfo; + for (const change of sortEditorTextChangesForOriginalDocument(pendingContentChange.changes)) { + const nextInfo = applyEditorTextChangeToLargeEditorModeInfo( + largeEditorInfo, + change, + bufferContent.length, + ); + if (!nextInfo) { + largeEditorInfo = null; + break; + } + largeEditorInfo = nextInfo; + } + } + if (!largeEditorInfo && previousSnapshot?.id === activeBuffer.id) { + largeEditorInfo = applyIncrementalLargeEditorModeInfo( + previousSnapshot.content, + bufferContent, + previousSnapshot.largeEditorInfo, + ); + } + largeEditorInfo ??= getLargeEditorModeInfo(bufferContent); + if (largeEditorInfo.largeContentMode) { const lines: string[] = []; previousActiveBufferSnapshot = { id: activeBuffer.id, - content: activeBuffer.content, + content: bufferContent, lines, + largeEditorInfo, }; useEditorViewStore.setState({ lines, lineCount: largeEditorInfo.lineCount, + tooLargeForEditorServices: true, }); return; } - 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 previousLines = + previousSnapshot?.id === activeBuffer.id ? previousSnapshot.lines.slice() : [""]; + let changedLines: string[] | null = null; + if (canApplyQueuedChange && pendingContentChange) { + changedLines = previousLines; + for (const change of sortEditorTextChangesForOriginalDocument(pendingContentChange.changes)) { + const nextLines = applyEditorTextChangeToLines(changedLines, change); + if (!nextLines) { + changedLines = null; + break; + } + changedLines = nextLines; + } + } const lines = previousSnapshot?.id === activeBuffer.id ? (changedLines ?? - applyIncrementalLineEdit(previousSnapshot.content, activeBuffer.content, previousLines) ?? - activeBuffer.content.split("\n")) - : activeBuffer.content.split("\n"); + applyIncrementalLineEdit( + previousSnapshot.content, + bufferContent, + previousSnapshot.lines.slice(), + ) ?? + bufferContent.split("\n")) + : bufferContent.split("\n"); previousActiveBufferSnapshot = { id: activeBuffer.id, - content: activeBuffer.content, + content: bufferContent, lines, + largeEditorInfo, }; useEditorViewStore.setState({ lines, lineCount: lines.length, + tooLargeForEditorServices: largeEditorInfo.largeContentMode, }); } else { previousActiveBufferSnapshot = null; useEditorViewStore.setState({ lines: [""], lineCount: 1, + tooLargeForEditorServices: false, }); } }); diff --git a/windows/tauri/src/features/editor/types/editor.types.ts b/windows/tauri/src/features/editor/types/editor.types.ts index 4eb0d51e6..8a03a5154 100644 --- a/windows/tauri/src/features/editor/types/editor.types.ts +++ b/windows/tauri/src/features/editor/types/editor.types.ts @@ -30,6 +30,7 @@ export interface EditorContentChangeOptions { contentAlreadyApplied?: boolean; skipUndoGrouping?: boolean; contentChange?: EditorTextChange; + contentChanges?: EditorTextChange[]; } export interface Cursor { diff --git a/windows/tauri/src/features/editor/utils/editor-text-change.test.ts b/windows/tauri/src/features/editor/utils/editor-text-change.test.ts new file mode 100644 index 000000000..3dccaa7ff --- /dev/null +++ b/windows/tauri/src/features/editor/utils/editor-text-change.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { applyEditorTextChangesToContent } from "./editor-text-change"; + +describe("applyEditorTextChangesToContent", () => { + test("inserts a character without scanning the rest of the document", () => { + expect( + applyEditorTextChangesToContent("hello", [ + { rangeOffset: 5, rangeLength: 0, text: "!" }, + ]), + ).toBe("hello!"); + }); + + test("applies multiple original-document edits from the end", () => { + expect( + applyEditorTextChangesToContent("abcde", [ + { rangeOffset: 1, rangeLength: 1, text: "B" }, + { rangeOffset: 3, rangeLength: 1, text: "D" }, + ]), + ).toBe("aBcDe"); + }); +}); diff --git a/windows/tauri/src/features/editor/utils/editor-text-change.ts b/windows/tauri/src/features/editor/utils/editor-text-change.ts new file mode 100644 index 000000000..4ea8dc47e --- /dev/null +++ b/windows/tauri/src/features/editor/utils/editor-text-change.ts @@ -0,0 +1,23 @@ +import type { EditorTextChange } from "../types/editor.types"; + +export function applyEditorTextChangesToContent( + content: string, + changes: readonly EditorTextChange[], +): string { + if (changes.length === 0) return content; + + const ordered = [...changes].sort((left, right) => right.rangeOffset - left.rangeOffset); + let next = content; + for (const change of ordered) { + const start = Math.max(0, Math.min(change.rangeOffset, next.length)); + const end = Math.max(start, Math.min(start + Math.max(0, change.rangeLength), next.length)); + next = `${next.slice(0, start)}${change.text}${next.slice(end)}`; + } + return next; +} + +export function sortEditorTextChangesForOriginalDocument( + changes: readonly EditorTextChange[], +): EditorTextChange[] { + return [...changes].sort((left, right) => right.rangeOffset - left.rangeOffset); +} diff --git a/windows/tauri/src/features/editor/utils/large-file.test.ts b/windows/tauri/src/features/editor/utils/large-file.test.ts new file mode 100644 index 000000000..f691ed888 --- /dev/null +++ b/windows/tauri/src/features/editor/utils/large-file.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { + applyEditorTextChangeToLargeEditorModeInfo, + applyIncrementalLargeEditorModeInfo, + getLargeEditorModeInfo, + isTooLargeForEditorServices, +} from "./large-file"; + +describe("large editor service gating", () => { + test("treats 2 MiB files as too large for expensive editor services", () => { + expect( + isTooLargeForEditorServices({ + contentLength: 2 * 1024 * 1024, + lineCount: 10, + }), + ).toBe(true); + }); + + test("treats 50,000-line files as too large for expensive editor services", () => { + expect( + isTooLargeForEditorServices({ + contentLength: 100, + lineCount: 50_000, + }), + ).toBe(true); + }); + + test("does not build unused line offsets on the typing path", () => { + const info = getLargeEditorModeInfo("one\ntwo\nthree"); + expect(info.lineCount).toBe(3); + expect(info.lineOffsets).toBeUndefined(); + }); + + test("updates line count from a range change without scanning the document", () => { + const previous = getLargeEditorModeInfo("one\ntwo"); + const next = applyEditorTextChangeToLargeEditorModeInfo( + previous, + { text: "\nthree", startLine: 1, endLine: 1 }, + "one\ntwo\nthree".length, + ); + expect(next?.lineCount).toBe(3); + expect(next?.lineOffsets).toBeUndefined(); + }); + + test("falls back to a prefix/suffix incremental update", () => { + const previous = getLargeEditorModeInfo("one\ntwo"); + const next = applyIncrementalLargeEditorModeInfo("one\ntwo", "one\ntwo\nthree", previous); + expect(next?.lineCount).toBe(3); + }); +}); diff --git a/windows/tauri/src/features/editor/utils/large-file.ts b/windows/tauri/src/features/editor/utils/large-file.ts index 746b06bc0..3a693bd82 100644 --- a/windows/tauri/src/features/editor/utils/large-file.ts +++ b/windows/tauri/src/features/editor/utils/large-file.ts @@ -151,11 +151,15 @@ export function shouldUseLargeEditorMode(content: string): boolean { return false; } -export function getLargeEditorModeInfo(content: string): LargeEditorModeInfo { +export function getLargeEditorModeInfo( + content: string, + options?: { includeLineOffsets?: boolean }, +): LargeEditorModeInfo { if (content.length === 0) { return { lineCount: 1, largeContentMode: false }; } + const includeLineOffsets = options?.includeLineOffsets === true; let lineCount = 1; let crossedResponsiveLineThreshold = false; let lineOffsets: number[] | undefined; @@ -166,6 +170,7 @@ export function getLargeEditorModeInfo(content: string): LargeEditorModeInfo { if (lineCount >= RESPONSIVE_LARGE_FILE_LINE_THRESHOLD) { crossedResponsiveLineThreshold = true; + if (!includeLineOffsets) continue; if (lineOffsets) { lineOffsets.push(index + 1); } else { @@ -181,7 +186,7 @@ export function getLargeEditorModeInfo(content: string): LargeEditorModeInfo { crossedResponsiveLineThreshold || isTooLargeForEditorServices({ contentLength: content.length, lineCount }); - if (largeContentMode && !lineOffsets) { + if (includeLineOffsets && largeContentMode && !lineOffsets) { lineOffsets = buildLineOffsets(content); } @@ -226,29 +231,36 @@ export function applyIncrementalLargeEditorModeInfo( contentLength: nextContent.length, lineCount, }); - let lineOffsets: number[] | undefined; - if (largeContentMode) { - if (previousInfo.lineOffsets) { - lineOffsets = updateLineOffsetsForEdit( - previousInfo.lineOffsets, - prefixLength, - previousEndOffset, - insertedText, - nextContent.length - previousContent.length, - ); - if (lineOffsets.length !== lineCount) { - return null; - } - } else { - lineOffsets = buildLineOffsets(nextContent); - } + return { + lineCount, + largeContentMode, + }; +} + +export function applyEditorTextChangeToLargeEditorModeInfo( + previousInfo: LargeEditorModeInfo, + change: { text: string; startLine?: number; endLine?: number }, + nextContentLength: number, +): LargeEditorModeInfo | null { + if (change.startLine === undefined || change.endLine === undefined) { + return null; + } + if (change.startLine < 0 || change.endLine < change.startLine) { + return null; } + const insertedNewlines = countNewlines(change.text); + const removedNewlines = change.endLine - change.startLine; + const lineCount = Math.max(1, previousInfo.lineCount + insertedNewlines - removedNewlines); return { lineCount, - largeContentMode, - lineOffsets, + largeContentMode: + previousInfo.largeContentMode || + isTooLargeForEditorServices({ + contentLength: nextContentLength, + lineCount, + }), }; } diff --git a/windows/tauri/src/features/git/api/git-blame-api.ts b/windows/tauri/src/features/git/api/git-blame-api.ts index 6c0b07346..c3a4505ee 100644 --- a/windows/tauri/src/features/git/api/git-blame-api.ts +++ b/windows/tauri/src/features/git/api/git-blame-api.ts @@ -11,7 +11,7 @@ export interface ResolvedGitBlame { export const getResolvedGitBlame = async ( rootPath: string, filePath: string, - content: string, + operationId?: string, ): Promise => { try { const resolved = await resolveRepositoryForFile(rootPath, filePath); @@ -22,7 +22,7 @@ export const getResolvedGitBlame = async ( const blame = await tauriInvoke("git_blame_file", { rootPath: resolved.repoPath, filePath: resolved.filePath, - content, + operationId, }); return { blame, @@ -40,7 +40,6 @@ export const getResolvedGitBlame = async ( export const getGitBlame = async ( rootPath: string, filePath: string, - content: string, ): Promise => { - return (await getResolvedGitBlame(rootPath, filePath, content))?.blame ?? null; + return (await getResolvedGitBlame(rootPath, filePath))?.blame ?? null; }; diff --git a/windows/tauri/src/features/git/hooks/use-git-blame.ts b/windows/tauri/src/features/git/hooks/use-git-blame.ts index 5cc5acbe8..0729d94a0 100644 --- a/windows/tauri/src/features/git/hooks/use-git-blame.ts +++ b/windows/tauri/src/features/git/hooks/use-git-blame.ts @@ -5,9 +5,7 @@ import { getGitBlameCacheKey, useGitBlameStore } from "../stores/git-blame.store import type { GitBlameLine } from "../types/git.types"; import { findGitBlameLine } from "../utils/git-blame-lines"; -const BLAME_REFRESH_DELAY_MS = 500; - -export function useGitBlame(filePath: string | undefined, content: string) { +export function useGitBlame(filePath: string | undefined) { const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const loadBlameForFile = useGitBlameStore((state) => state.actions.loadBlameForFile); const clearBlameForFile = useGitBlameStore((state) => state.actions.clearBlameForFile); @@ -15,19 +13,13 @@ export function useGitBlame(filePath: string | undefined, content: string) { const cacheKey = filePath && rootFolderPath ? getGitBlameCacheKey(rootFolderPath, filePath) : null; const blameData = useGitBlameStore((state) => - cacheKey && state.blameContent.get(cacheKey) === content - ? state.blameData.get(cacheKey) - : undefined, + cacheKey ? state.blameData.get(cacheKey) : undefined, ); + useEffect(() => { if (!filePath || !rootFolderPath) return; - - const timeoutId = window.setTimeout(() => { - void loadBlameForFile(rootFolderPath, filePath, content); - }, BLAME_REFRESH_DELAY_MS); - - return () => window.clearTimeout(timeoutId); - }, [blameRevision, content, filePath, loadBlameForFile, rootFolderPath]); + void loadBlameForFile(rootFolderPath, filePath); + }, [blameRevision, filePath, loadBlameForFile, rootFolderPath]); useEffect(() => { if (!filePath) return; diff --git a/windows/tauri/src/features/git/stores/git-blame.store.ts b/windows/tauri/src/features/git/stores/git-blame.store.ts index 1aca3eaa5..4cc5f1f2f 100644 --- a/windows/tauri/src/features/git/stores/git-blame.store.ts +++ b/windows/tauri/src/features/git/stores/git-blame.store.ts @@ -1,4 +1,5 @@ import { createStore } from "zustand/vanilla"; +import { cancelCoreOperation } from "@/core/lithe-core-client"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { getResolvedGitBlame } from "../api/git-blame-api"; import type { GitBlame, GitBlameLine } from "../types/git.types"; @@ -6,9 +7,8 @@ import { findGitBlameLine } from "../utils/git-blame-lines"; interface GitBlameState { blameData: Map; - blameContent: Map; - requestedContent: Map; requestIds: Map; + operationIds: Map; nextRequestId: number; revision: number; isLoading: Map; @@ -16,7 +16,7 @@ interface GitBlameState { fileToRepo: Map; actions: { - loadBlameForFile: (repoPath: string, filePath: string, content: string) => Promise; + loadBlameForFile: (repoPath: string, filePath: string) => Promise; clearBlameForFile: (filePath: string) => void; clearAllBlame: () => void; getBlameForLine: (filePath: string, lineNumber: number) => GitBlameLine | null; @@ -30,9 +30,8 @@ export const getGitBlameCacheKey = (repoPath: string, filePath: string) => export const createGitBlameStore = () => createStore()((set, get) => ({ blameData: new Map(), - blameContent: new Map(), - requestedContent: new Map(), requestIds: new Map(), + operationIds: new Map(), nextRequestId: 0, revision: 0, isLoading: new Map(), @@ -40,49 +39,55 @@ export const createGitBlameStore = () => fileToRepo: new Map(), actions: { - loadBlameForFile: async (repoPath: string, filePath: string, content: string) => { + loadBlameForFile: async (repoPath: string, filePath: string) => { const state = get(); const cacheKey = getGitBlameCacheKey(repoPath, filePath); - const contentIsCurrent = state.requestedContent.get(cacheKey) === content; - const contentIsLoaded = - state.blameContent.get(cacheKey) === content && state.blameData.has(cacheKey); - - if (contentIsCurrent && (state.isLoading.get(cacheKey) || contentIsLoaded)) { + if (state.blameData.has(cacheKey) && !state.errors.has(cacheKey)) { + return; + } + if (state.isLoading.get(cacheKey)) { return; } + const previousOperationId = state.operationIds.get(cacheKey); + if (previousOperationId) { + void cancelCoreOperation(previousOperationId); + } + const requestId = state.nextRequestId + 1; + const operationId = crypto.randomUUID(); const errors = new Map(state.errors); errors.delete(cacheKey); set({ - requestedContent: new Map(state.requestedContent).set(cacheKey, content), requestIds: new Map(state.requestIds).set(cacheKey, requestId), + operationIds: new Map(state.operationIds).set(cacheKey, operationId), nextRequestId: requestId, isLoading: new Map(state.isLoading).set(cacheKey, true), errors, }); - const result = await getResolvedGitBlame(repoPath, filePath, content); + const result = await getResolvedGitBlame(repoPath, filePath, operationId); if (get().requestIds.get(cacheKey) !== requestId) { return; } + const operationIds = new Map(get().operationIds); + operationIds.delete(cacheKey); + if (result) { set({ blameData: new Map(get().blameData).set(cacheKey, result.blame), - blameContent: new Map(get().blameContent).set(cacheKey, content), fileToRepo: new Map(get().fileToRepo).set(filePath, result.repoPath), + operationIds, isLoading: new Map(get().isLoading).set(cacheKey, false), }); } else { const blameData = new Map(get().blameData); - const blameContent = new Map(get().blameContent); blameData.delete(cacheKey); - blameContent.delete(cacheKey); set({ blameData, - blameContent, + operationIds, errors: new Map(get().errors).set(cacheKey, "Failed to load blame data"), isLoading: new Map(get().isLoading).set(cacheKey, false), }); @@ -92,9 +97,8 @@ export const createGitBlameStore = () => clearBlameForFile: (filePath: string) => { const state = get(); const blameData = new Map(state.blameData); - const blameContent = new Map(state.blameContent); - const requestedContent = new Map(state.requestedContent); const requestIds = new Map(state.requestIds); + const operationIds = new Map(state.operationIds); const isLoading = new Map(state.isLoading); const errors = new Map(state.errors); const fileToRepo = new Map(state.fileToRepo); @@ -102,17 +106,19 @@ export const createGitBlameStore = () => const suffix = `\0${filePath}`; for (const key of new Set([ ...blameData.keys(), - ...blameContent.keys(), - ...requestedContent.keys(), ...requestIds.keys(), + ...operationIds.keys(), ...isLoading.keys(), ...errors.keys(), ])) { if (!key.endsWith(suffix)) continue; + const operationId = operationIds.get(key); + if (operationId) { + void cancelCoreOperation(operationId); + } blameData.delete(key); - blameContent.delete(key); - requestedContent.delete(key); requestIds.delete(key); + operationIds.delete(key); isLoading.delete(key); errors.delete(key); } @@ -120,9 +126,8 @@ export const createGitBlameStore = () => set({ blameData, - blameContent, - requestedContent, requestIds, + operationIds, revision: state.revision + 1, isLoading, errors, @@ -131,11 +136,13 @@ export const createGitBlameStore = () => }, clearAllBlame: () => { + for (const operationId of get().operationIds.values()) { + void cancelCoreOperation(operationId); + } set({ blameData: new Map(), - blameContent: new Map(), - requestedContent: new Map(), requestIds: new Map(), + operationIds: new Map(), revision: get().revision + 1, isLoading: new Map(), errors: new Map(), diff --git a/windows/tauri/src/features/layout/components/footer/footer-editor-status.tsx b/windows/tauri/src/features/layout/components/footer/footer-editor-status.tsx index 7e34c268e..6490803c5 100644 --- a/windows/tauri/src/features/layout/components/footer/footer-editor-status.tsx +++ b/windows/tauri/src/features/layout/components/footer/footer-editor-status.tsx @@ -21,7 +21,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { CheckCircleIcon, HardDrivesIcon, LockIcon, LockOpenIcon } from "@/ui/icons"; import { FooterStatusChip, FooterStatusLabel } from "./footer-status-chip"; -const MEMORY_POLL_INTERVAL_MS = 2000; +const MEMORY_POLL_INTERVAL_MS = 10_000; export function useFooterEditorStatusItems(): Array | null> { const { t } = useTranslation(); @@ -46,8 +46,21 @@ export function useFooterEditorStatusItems(): Array void poller.poll(), MEMORY_POLL_INTERVAL_MS); + + const intervalId = window.setInterval(() => { + if (document.visibilityState === "hidden") return; + void poller.poll(); + }, MEMORY_POLL_INTERVAL_MS); + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void poller.poll(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); window.clearInterval(intervalId); poller.stop(); }; diff --git a/windows/tauri/src/features/layout/components/main-layout.tsx b/windows/tauri/src/features/layout/components/main-layout.tsx index 4893c2b83..2e710ed83 100644 --- a/windows/tauri/src/features/layout/components/main-layout.tsx +++ b/windows/tauri/src/features/layout/components/main-layout.tsx @@ -23,9 +23,11 @@ import { cn } from "@/utils/cn"; import { frontendTrace } from "@/utils/frontend-trace"; import { recordStartupMilestone } from "@/features/bootstrap/startup-performance"; import { getInternalTabDragData } from "@/features/tabs/utils/internal-tab-drag"; +import { PendingBufferCloseDialog } from "@/features/window/components/pending-buffer-close-dialog"; import TitleBarWithSettings from "../../window/components/title-bar/title-bar"; import { ProjectTabBar } from "../../window/components/project-tab-bar"; import Footer from "./footer/footer"; +import { WorkbenchErrorBoundary } from "./workbench-error-boundary"; import { ResizablePane } from "./resizable-pane"; import { COLLAPSED_ACTIVITY_RAIL_WIDTH, @@ -289,7 +291,9 @@ export function MainLayout() { "rounded-r-xl", )} > - + + +
{terminalWidthMode === "editor" && deferredSurfacesReady && ( @@ -316,6 +320,8 @@ export function MainLayout() { )} + + {/* Global modals and overlays */} {deferredSurfacesReady ? ( diff --git a/windows/tauri/src/features/layout/components/workbench-error-boundary.tsx b/windows/tauri/src/features/layout/components/workbench-error-boundary.tsx new file mode 100644 index 000000000..d877404bd --- /dev/null +++ b/windows/tauri/src/features/layout/components/workbench-error-boundary.tsx @@ -0,0 +1,57 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Button } from "@/ui/button"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { createTranslator } from "@/i18n/locale"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/ui/empty"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class WorkbenchErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Workbench render error:", error, errorInfo); + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + const t = createTranslator(useSettingsStore.getState().settings.displayLanguage); + + return ( + + + {t("workbench.renderErrorTitle")} + + {this.state.error?.message || t("workbench.renderErrorDescription")} + + + + + + + ); + } +} diff --git a/windows/tauri/src/features/panes/types/pane-content.types.ts b/windows/tauri/src/features/panes/types/pane-content.types.ts index 2c281f45a..fcd82d483 100644 --- a/windows/tauri/src/features/panes/types/pane-content.types.ts +++ b/windows/tauri/src/features/panes/types/pane-content.types.ts @@ -64,6 +64,12 @@ export interface EditorContent extends PaneContentBase { language?: string; languageOverride?: string; tokens: TokenEntry[]; + /** + * Bumped only for content that did not originate from the local editor + * model (reload, undo-restore, format-on-save). Local typing must not + * change this so Monaco can stay the in-session source of truth. + */ + contentRevision?: number; } export interface TerminalContent extends PaneContentBase { @@ -100,6 +106,7 @@ export interface DiffContent extends PaneContentBase { content: string; savedContent: string; diffData?: GitDiff | MultiFileDiff; + contentRevision?: number; } interface ImageContent extends PaneContentBase { diff --git a/windows/tauri/src/features/tabs/components/tab-bar.tsx b/windows/tauri/src/features/tabs/components/tab-bar.tsx index 962a760b7..ac74a6e7e 100644 --- a/windows/tauri/src/features/tabs/components/tab-bar.tsx +++ b/windows/tauri/src/features/tabs/components/tab-bar.tsx @@ -28,12 +28,10 @@ import { findPaneGroup } from "@/features/panes/utils/pane-tree"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import type { PaneContent } from "@/features/panes/types/pane-content.types"; -import { useEditorAppStore } from "@/features/editor/stores/editor-app.store"; import { getChromeNavigationIndex } from "@/features/layout/utils/chrome-keyboard"; import { useSidebarStore } from "@/features/layout/stores/sidebar.store"; import { useTerminalStore } from "@/features/terminal/stores/terminal.store"; import { useWebViewerNavigationStore } from "@/features/viewer/web/stores/web-viewer-navigation.store"; -import UnsavedChangesDialog from "@/features/window/components/unsaved-changes-dialog"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { Button } from "@/ui/button"; import { ContextMenu, ContextMenuTrigger } from "@/ui/context-menu"; @@ -49,6 +47,7 @@ import { } from "../utils/internal-tab-drag"; import TabBarItem from "./tab-bar-item"; import TabContextMenu from "./tab-context-menu"; +import { tabChromeBuffersEqual, toTabChromeBuffer } from "../utils/tab-chrome-buffer"; interface TabBarProps { paneId?: string; @@ -63,7 +62,6 @@ const TabBar = ({ }: TabBarProps) => { const { t } = useTranslation(); // Get everything from stores - const pendingClose = useBufferStore.use.pendingClose(); const paneRoot = usePaneStore.use.root(); const bottomRoot = usePaneStore.use.bottomRoot(); const fullscreenPaneId = usePaneStore.use.fullscreenPaneId(); @@ -79,12 +77,15 @@ const TabBar = ({ const paneBufferIdSet = useMemo(() => { return pane ? new Set(pane.bufferIds) : null; }, [pane?.bufferIds]); - const buffers = useBufferStore((state) => { - const visibleBuffers = paneBufferIdSet - ? state.buffers.filter((buffer) => paneBufferIdSet.has(buffer.id)) - : state.buffers; - return visibleBuffers.filter((buffer) => buffer.type !== "newTab"); - }); + const buffers = useBufferStore( + (state) => { + const visibleBuffers = paneBufferIdSet + ? state.buffers.filter((buffer) => paneBufferIdSet.has(buffer.id)) + : state.buffers; + return visibleBuffers.filter((buffer) => buffer.type !== "newTab").map(toTabChromeBuffer); + }, + tabChromeBuffersEqual, + ); const globalActiveBufferId = useBufferStore((state) => (pane ? null : state.activeBufferId)); const activeBufferCandidate = pane ? pane.activeBufferId : globalActiveBufferId; const { @@ -95,12 +96,9 @@ const TabBar = ({ handleCloseAllTabs, handleCloseTabsToRight, reorderBuffers, - confirmCloseWithoutSaving, - cancelPendingClose, convertPreviewToDefinite, showNewTabView, } = useBufferStore.use.actions(); - const { handleSave } = useEditorAppStore.use.actions(); const horizontalTabScroll = useSettingsStore((state) => state.settings.horizontalTabScroll); const maxOpenTabs = useSettingsStore((state) => state.settings.maxOpenTabs); const updateActivePath = useSidebarStore.use.actions().updateActivePath; @@ -401,27 +399,6 @@ const TabBar = ({ [rootFolderPath], ); - const handleSaveAndClose = useCallback(async () => { - if (!pendingClose) return; - - const buffer = bufferById.get(pendingClose.bufferId); - if (!buffer) return; - - // Save the file - await handleSave(); - - // Then proceed with closing - confirmCloseWithoutSaving(); - }, [pendingClose, bufferById, handleSave, confirmCloseWithoutSaving]); - - const handleDiscardAndClose = useCallback(() => { - confirmCloseWithoutSaving(); - }, [confirmCloseWithoutSaving]); - - const handleCancelClose = useCallback(() => { - cancelPendingClose(); - }, [cancelPendingClose]); - const closeTab = useCallback( (bufferId: string) => { handleTabClose(bufferId); @@ -870,15 +847,6 @@ const TabBar = ({ - {pendingClose && ( - - )} - {/* Screen reader live region for announcements */}
{srAnnouncement} diff --git a/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.test.ts b/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.test.ts new file mode 100644 index 000000000..4530e85b4 --- /dev/null +++ b/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { tabChromeBuffersEqual, toTabChromeBuffer } from "./tab-chrome-buffer"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; + +function editorBuffer(overrides: Partial = {}): EditorContent { + return { + id: "buffer-1", + type: "editor", + path: "src/main.ts", + name: "main.ts", + isPinned: false, + isPreview: false, + isActive: true, + content: "lots of file text", + savedContent: "lots of file text", + isDirty: true, + isVirtual: false, + tokens: [{ start: 0, end: 4, token_type: "keyword", class_name: "k" }], + ...overrides, + }; +} + +describe("toTabChromeBuffer", () => { + test("drops editor content so tab chrome does not rerender on typing", () => { + const buffer = editorBuffer(); + + expect(toTabChromeBuffer(buffer)).toEqual({ + ...buffer, + content: "", + savedContent: "", + tokens: [], + }); + }); + + test("treats content-only updates as equal tab chrome", () => { + const before = [toTabChromeBuffer(editorBuffer({ content: "a" }))]; + const after = [toTabChromeBuffer(editorBuffer({ content: "ab" }))]; + expect(tabChromeBuffersEqual(before, after)).toBe(true); + }); + + test("rerenders tabs when dirty metadata changes", () => { + const before = [toTabChromeBuffer(editorBuffer({ isDirty: false }))]; + const after = [toTabChromeBuffer(editorBuffer({ isDirty: true }))]; + expect(tabChromeBuffersEqual(before, after)).toBe(false); + }); + + test("does not throw when comparing against a missing previous snapshot", () => { + expect(tabChromeBuffersEqual(undefined, [toTabChromeBuffer(editorBuffer())])).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.ts b/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.ts new file mode 100644 index 000000000..def4039bf --- /dev/null +++ b/windows/tauri/src/features/tabs/utils/tab-chrome-buffer.ts @@ -0,0 +1,55 @@ +import type { PaneContent } from "@/features/panes/types/pane-content.types"; + +const EMPTY_TOKENS: never[] = []; + +export function toTabChromeBuffer(buffer: PaneContent): PaneContent { + if (buffer.type === "editor") { + return { + ...buffer, + content: "", + savedContent: "", + tokens: EMPTY_TOKENS, + }; + } + + if (buffer.type === "diff") { + return { + ...buffer, + content: "", + savedContent: "", + }; + } + + return buffer; +} + +function tabChromeBufferEqual(left: PaneContent, right: PaneContent): boolean { + if (left.id !== right.id || left.type !== right.type) return false; + if ( + left.path !== right.path || + left.name !== right.name || + left.isPinned !== right.isPinned || + left.isPreview !== right.isPreview || + left.isActive !== right.isActive + ) { + return false; + } + + if (left.type === "editor" && right.type === "editor") { + return left.isDirty === right.isDirty && left.isVirtual === right.isVirtual; + } + + return true; +} + +export function tabChromeBuffersEqual( + left: readonly PaneContent[] | null | undefined, + right: readonly PaneContent[] | null | undefined, +): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (!tabChromeBufferEqual(left[index], right[index])) return false; + } + return true; +} diff --git a/windows/tauri/src/features/window/components/pending-buffer-close-dialog.tsx b/windows/tauri/src/features/window/components/pending-buffer-close-dialog.tsx new file mode 100644 index 000000000..3f7f4835d --- /dev/null +++ b/windows/tauri/src/features/window/components/pending-buffer-close-dialog.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { getBufferById } from "@/features/editor/utils/buffer-index"; +import { useEditorAppStore } from "@/features/editor/stores/editor-app.store"; +import UnsavedChangesDialog from "@/features/window/components/unsaved-changes-dialog"; + +export function PendingBufferCloseDialog() { + const pendingClose = useBufferStore.use.pendingClose(); + const fileName = useBufferStore((state) => { + if (!state.pendingClose) return ""; + return getBufferById(state.buffers, state.pendingClose.bufferId)?.name ?? ""; + }); + const { confirmCloseWithoutSaving, cancelPendingClose } = useBufferStore.use.actions(); + const { handleSave } = useEditorAppStore.use.actions(); + + const handleSaveAndClose = useCallback(async () => { + if (!pendingClose) return; + await handleSave(); + confirmCloseWithoutSaving(); + }, [confirmCloseWithoutSaving, handleSave, pendingClose]); + + if (!pendingClose) return null; + + return ( + void handleSaveAndClose()} + onDiscard={confirmCloseWithoutSaving} + onCancel={cancelPendingClose} + /> + ); +} diff --git a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx index f995640c0..1e1c14e48 100644 --- a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx +++ b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx @@ -93,29 +93,57 @@ const TitleBar = ({ showMinimal = false, onOpenProjectPicker }: TitleBarProps) = const window = getCurrentWindow(); setCurrentWindow(window); + let resizeSyncTimer: ReturnType | undefined; + let syncWindowStateInFlight = false; + let pendingWindowStateSync = false; const syncWindowState = async () => { + if (syncWindowStateInFlight) { + pendingWindowStateSync = true; + return; + } + syncWindowStateInFlight = true; try { - const [maximized, fullscreen] = await Promise.all([ - window.isMaximized(), - window.isFullscreen(), - ]); - setIsMaximized(maximized); - setIsFullscreen(fullscreen); - } catch (error) { - console.error("Error checking window state:", error); + do { + pendingWindowStateSync = false; + try { + const [maximized, fullscreen] = await Promise.all([ + window.isMaximized(), + window.isFullscreen(), + ]); + setIsMaximized(maximized); + setIsFullscreen(fullscreen); + } catch (error) { + console.error("Error checking window state:", error); + } + } while (pendingWindowStateSync); + } finally { + syncWindowStateInFlight = false; } }; + const scheduleWindowStateSync = () => { + if (resizeSyncTimer !== undefined) { + globalThis.clearTimeout(resizeSyncTimer); + } + resizeSyncTimer = globalThis.setTimeout(() => { + resizeSyncTimer = undefined; + void syncWindowState(); + }, 100); + }; + try { await syncWindowState(); const unlistenResize = await window.onResized(() => { - void syncWindowState(); + scheduleWindowStateSync(); }); const unlistenFocus = await window.onFocusChanged(() => { - void syncWindowState(); + scheduleWindowStateSync(); }); return () => { + if (resizeSyncTimer !== undefined) { + globalThis.clearTimeout(resizeSyncTimer); + } unlistenResize(); unlistenFocus(); }; diff --git a/windows/tauri/src/features/workspace/stores/create-workspace-scoped-store.ts b/windows/tauri/src/features/workspace/stores/create-workspace-scoped-store.ts index b8c0f9c34..f16a3f6df 100644 --- a/windows/tauri/src/features/workspace/stores/create-workspace-scoped-store.ts +++ b/windows/tauri/src/features/workspace/stores/create-workspace-scoped-store.ts @@ -6,6 +6,7 @@ import { createSelectors, type WithSelectors } from "@/utils/zustand-selectors"; type WorkspaceStoreHook = UseBoundStore> & { getStore: (workspaceId: string) => StoreApi; + (selector: (state: T) => U, equalityFn?: (left: U, right: U) => boolean): U; }; export type WorkspaceScopedStore = WithSelectors>; @@ -34,7 +35,10 @@ export function createWorkspaceScopedStore( ): WorkspaceScopedStore { workspaceRuntimeRegistry.registerStore(key, factory); - const useWorkspaceStore = ((selector?: (state: T) => U): U => { + const useWorkspaceStore = (( + selector?: (state: T) => U, + compare?: EqualityFn, + ): U => { const scopedWorkspaceId = useWorkspaceStoreScopeId(); const getScopedWorkspaceId = useMemo( () => () => scopedWorkspaceId ?? workspaceRuntimeRegistry.getActiveWorkspaceId(), @@ -49,7 +53,7 @@ export function createWorkspaceScopedStore( return useStoreWithEqualityFn( store, selector ?? ((state: T) => state as unknown as U), - equalityFn, + compare ?? equalityFn, ); }) as WorkspaceStoreHook; diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 28fc8a3a1..eadd67428 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1691,6 +1691,8 @@ const catalogs = { "workbench.moreProjectActions": "More project actions", "workbench.emptyEditorTitle": "Select a file to review", "workbench.emptyEditorDescription": "Changes from external tools will appear automatically.", + "workbench.renderErrorTitle": "The editor failed to render", + "workbench.renderErrorDescription": "Retry to restore this surface without restarting Lithe.", "welcome.openProject": "Open Project", "welcome.recentProjects": "Recent Projects", "welcome.title": "Welcome to Lithe", @@ -3882,6 +3884,9 @@ const catalogs = { "editor.preferences": "Editor preferences", "editor.minimap": "Minimap", "editor.inlineGitBlame": "Inline Git Blame", + "editor.largeFileServicesDisabled": + "Language intelligence, Git blame, Code Lens, and semantic highlighting are off for this large file.", + "editor.enableLargeFileServices": "Enable anyway", "editor.selectAll": "Select All", "editor.duplicateLine": "Duplicate Line", "editor.addSelectionToNextMatch": "Add Selection to Next Match", @@ -5766,6 +5771,8 @@ const catalogs = { "workbench.moreProjectActions": "更多项目操作", "workbench.emptyEditorTitle": "选择文件以查看", "workbench.emptyEditorDescription": "外部工具产生的更改会自动显示。", + "workbench.renderErrorTitle": "编辑器界面渲染失败", + "workbench.renderErrorDescription": "可以重试恢复此界面,无需重启 Lithe。", "welcome.openProject": "打开项目", "welcome.recentProjects": "最近项目", "welcome.title": "欢迎使用 Lithe", @@ -7881,6 +7888,9 @@ const catalogs = { "editor.preferences": "编辑器首选项", "editor.minimap": "缩略图", "editor.inlineGitBlame": "行内 Git Blame", + "editor.largeFileServicesDisabled": + "此大文件已关闭语言智能、Git Blame、代码镜头和语义高亮。", + "editor.enableLargeFileServices": "仍然启用", "editor.selectAll": "全选", "editor.duplicateLine": "复制行", "editor.addSelectionToNextMatch": "将所选添加到下一处匹配", diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a90a0bfdf..42257b471 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -98,8 +98,9 @@ async function dispatchSessionEvent(session: Session, event: RuntimeEvent): Prom } async function poll(session: Session): Promise { - const response = await core<{ events: RuntimeEvent[] }>("lsp.pollEvents", { + const response = await core<{ events: RuntimeEvent[] }>("lsp.waitEvents", { sessionId: session.id, + timeoutMilliseconds: 30_000, }); for (const event of response.events) await dispatchSessionEvent(session, event); return response.events; @@ -118,20 +119,23 @@ async function runEventPump(session: Session): Promise { await emit("lsp://server-crashed", {}); return; } - await new Promise((resolve) => setTimeout(resolve, 20)); } } async function waitUntilReady(session: Session): Promise { const deadline = Date.now() + 12_000; while (Date.now() < deadline) { - const events = await poll(session); - const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; + const response = await core<{ events: RuntimeEvent[] }>("lsp.waitEvents", { + sessionId: session.id, + timeoutMilliseconds: 200, + }); + for (const event of response.events) await dispatchSessionEvent(session, event); + const state = [...response.events].reverse().find((event) => event.type === "stateChanged") + ?.state; if (state === "ready") return; if (state === "failed" || state === "stopped") { throw new Error(`Language server entered ${state} state`); } - await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error("Language server initialization timed out"); } @@ -334,13 +338,56 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom if (session.files.size === 0) await stopSession(session); return undefined as T; } - if (command === "lsp_document_open" || command === "lsp_document_change" || command === "lsp_document_save") { + if (command === "lsp_document_open") { + const session = sessionForFile(args.filePath); + await core("lsp.syncDocument", { + sessionId: session.id, + uri: fileUri(args.filePath), + languageId: args.languageId ?? session.languageId, + text: args.content ?? "", + }); + return undefined as T; + } + if (command === "lsp_document_change") { + const session = sessionForFile(args.filePath); + const contentChanges = Array.isArray(args.contentChanges) + ? args.contentChanges + .map((change: JsonRecord) => { + if ( + typeof change.startLine !== "number" || + typeof change.startColumn !== "number" || + typeof change.endLine !== "number" || + typeof change.endColumn !== "number" + ) { + return null; + } + return { + range: { + start: { line: change.startLine, utf16Column: change.startColumn }, + end: { line: change.endLine, utf16Column: change.endColumn }, + }, + text: String(change.text ?? ""), + }; + }) + .filter(Boolean) + : []; + await core("lsp.syncDocument", { + sessionId: session.id, + uri: fileUri(args.filePath), + languageId: args.languageId ?? session.languageId, + ...(contentChanges.length > 0 + ? { contentChanges } + : { text: args.content ?? "" }), + }); + return undefined as T; + } + if (command === "lsp_document_save") { const session = sessionForFile(args.filePath); await core("lsp.syncDocument", { sessionId: session.id, uri: fileUri(args.filePath), languageId: args.languageId ?? session.languageId, - text: args.content, + text: args.content ?? "", }); return undefined as T; } From 05be01c6ae1a2989b8e5c5c30b44eeb78271b272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 20 Aug 2026 20:10:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E4=B8=8E=20Git=20diff=20=E6=BB=9A=E8=BD=AE?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E9=BD=90=20JDTLS=20=E6=9F=A5=E6=89=BE?= =?UTF-8?q?=E5=B9=B6=E9=BB=98=E8=AE=A4=E5=BC=80=E5=90=AF=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- windows/tauri/src-tauri/src/lsp.rs | 103 +++++++++++++++++- .../components/diff/git-diff-editor-stack.tsx | 9 +- .../components/log/git-commit-file-tree.tsx | 13 ++- .../components/log/git-commit-inspector.tsx | 15 ++- .../git/components/log/git-reference-tree.tsx | 12 +- .../components/macos-settings-panels.tsx | 19 +++- .../settings/components/settings-dialog.tsx | 18 ++- .../settings/config/default-settings.ts | 2 +- .../lib/settings-normalization.test.ts | 6 + 9 files changed, 179 insertions(+), 18 deletions(-) diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 3ed6a9840..9f2f3a757 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -12,6 +12,7 @@ use tauri::{AppHandle, Manager}; const JAVA_PROVIDER_ID: &str = "java"; const MIN_JDTLS_JAVA_MAJOR_VERSION: u32 = 17; const JDTLS_EXECUTABLE_NAMES: &[&str] = &["jdtls.bat", "jdtls.cmd", "jdtls.exe", "jdtls"]; +const MAX_CURRENT_EXE_JDTLS_WALK_DEPTH: usize = 12; /// Launch plan for the built-in Java language server on this machine. #[derive(Debug, Clone, Serialize)] @@ -86,7 +87,7 @@ fn resolve_java_lsp_launch( ) -> Result { let executable = find_jdtls_executable(path_env, bundled_root, extra_roots).ok_or_else(|| { - "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH." + "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH, or use a Lithe build that includes the bundled Java language server." .to_string() })?; let java_home = resolve_java_home(java_home_override)?; @@ -142,6 +143,12 @@ fn jdtls_search_roots(project_root: Option<&Path>) -> Vec { if let Ok(home) = std::env::var("JDTLS_HOME") { roots.push(PathBuf::from(home)); } + if let Ok(root) = std::env::var("LITHE_JDTLS_ROOT") { + let trimmed = root.trim(); + if !trimmed.is_empty() { + roots.push(PathBuf::from(trimmed)); + } + } for key in ["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] { if let Ok(base) = std::env::var(key) { let base = PathBuf::from(base); @@ -169,10 +176,41 @@ fn jdtls_search_roots(project_root: Option<&Path>) -> Vec { } fn bundled_jdtls_root(app: &AppHandle) -> Option { - app.path() - .resource_dir() - .ok() - .map(|directory| directory.join("LanguageServers").join("jdtls")) + select_bundled_jdtls_root( + app.path().resource_dir().ok().as_deref(), + std::env::current_exe().ok().as_deref(), + ) +} + +fn is_jdtls_root(root: &Path) -> bool { + JDTLS_EXECUTABLE_NAMES + .iter() + .any(|name| root.join(name).is_file() || root.join("bin").join(name).is_file()) +} + +/// NSIS bundles JDTLS under the resource directory. Unbundled `cargo`/`build-windows` +/// executables do not, so also look next to the exe and walk up to repo `.artifacts/jdtls`. +fn select_bundled_jdtls_root( + resource_dir: Option<&Path>, + current_exe: Option<&Path>, +) -> Option { + let mut candidates = Vec::new(); + if let Some(directory) = resource_dir { + candidates.push(directory.join("LanguageServers").join("jdtls")); + } + if let Some(exe) = current_exe { + if let Some(exe_dir) = exe.parent() { + candidates.push(exe_dir.join("LanguageServers").join("jdtls")); + let mut cursor = exe_dir.to_path_buf(); + for _ in 0..MAX_CURRENT_EXE_JDTLS_WALK_DEPTH { + candidates.push(cursor.join(".artifacts").join("jdtls")); + if !cursor.pop() { + break; + } + } + } + } + candidates.into_iter().find(|root| is_jdtls_root(root)) } fn resolve_java_home(java_home_override: Option<&str>) -> Result { @@ -346,6 +384,61 @@ mod tests { fs::remove_dir_all(missing).ok(); } + #[test] + fn bundled_root_prefers_resource_dir_when_it_contains_jdtls() { + let resource_dir = temp_dir(); + let bundled = resource_dir + .join("LanguageServers") + .join("jdtls") + .join("bin"); + fs::create_dir_all(&bundled).expect("bundled bin"); + fs::write(bundled.join("jdtls.bat"), "@echo off\n").expect("jdtls"); + + let found = select_bundled_jdtls_root(Some(&resource_dir), None).expect("found"); + assert_eq!(found, resource_dir.join("LanguageServers").join("jdtls")); + fs::remove_dir_all(resource_dir).ok(); + } + + #[test] + fn bundled_root_walks_from_unbundled_exe_to_repo_artifacts() { + let repo = temp_dir(); + let artifacts_bin = repo.join(".artifacts").join("jdtls").join("bin"); + fs::create_dir_all(&artifacts_bin).expect("artifacts bin"); + fs::write(artifacts_bin.join("jdtls.bat"), "@echo off\n").expect("jdtls"); + + let exe_dir = repo + .join("windows") + .join("tauri") + .join("src-tauri") + .join("target") + .join("release"); + fs::create_dir_all(&exe_dir).expect("exe dir"); + let exe = exe_dir.join("lithe-windows.exe"); + fs::write(&exe, []).expect("exe"); + + let empty_resource = repo.join("empty-resource"); + fs::create_dir_all(empty_resource.join("LanguageServers").join("jdtls")) + .expect("empty resource"); + + let found = select_bundled_jdtls_root(Some(&empty_resource), Some(&exe)).expect("found"); + assert_eq!(found, repo.join(".artifacts").join("jdtls")); + fs::remove_dir_all(repo).ok(); + } + + #[test] + fn bundled_root_uses_language_servers_next_to_the_exe() { + let exe_dir = temp_dir(); + let bundled_bin = exe_dir.join("LanguageServers").join("jdtls").join("bin"); + fs::create_dir_all(&bundled_bin).expect("exe bundled bin"); + fs::write(bundled_bin.join("jdtls.bat"), "@echo off\n").expect("jdtls"); + let exe = exe_dir.join("lithe-windows.exe"); + fs::write(&exe, []).expect("exe"); + + let found = select_bundled_jdtls_root(None, Some(&exe)).expect("found"); + assert_eq!(found, exe_dir.join("LanguageServers").join("jdtls")); + fs::remove_dir_all(exe_dir).ok(); + } + #[test] fn parses_modern_and_legacy_java_major_versions() { assert_eq!(java_major_version("17.0.18"), Some(17)); diff --git a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx index c0b8686a7..6eb87d9b0 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx @@ -7,7 +7,7 @@ import { MagnifyingGlassIcon as Search, RowsIcon as Rows3, } from "@/ui/icons"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import CodeEditor from "@/features/editor/components/code-editor"; import Breadcrumb, { @@ -39,6 +39,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { joinPath } from "@/utils/path-helpers"; import { Avatar } from "@/ui/avatar"; import { Button } from "@/ui/button"; +import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel"; import { Empty, EmptyDescription } from "@/ui/empty"; import { DropdownMenu, @@ -1063,6 +1064,12 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ }; }, [isWorkingTree, multiDiff.commitHash, multiDiff.repoPath, rootFolderPath]); + useLayoutEffect(() => { + const element = diffStackScrollRef.current; + if (!element) return; + return bindScrollContainerWheel(element); + }, [isIndexingDiffs, multiDiff.files.length]); + return (
buildFileTree(files), [files]); const [collapsed, setCollapsed] = useState>(new Set()); + const scrollRef = useRef(null); + + useLayoutEffect(() => { + const element = scrollRef.current; + if (!element) return; + return bindScrollContainerWheel(element); + }, []); + const toggle = (path: string) => { setCollapsed((current) => { const next = new Set(current); @@ -157,7 +166,7 @@ export function GitCommitFileTree({ }; return ( -
+
{tree.map((node) => ( ("idle"); const [selectedPath, setSelectedPath] = useState(null); const requestIdRef = useRef(0); + const detailsScrollRef = useRef(null); const inspectorPanelLayout = useGitLogPreferencesStore.use.inspectorPanelLayout(); const { setInspectorPanelLayout } = useGitLogPreferencesStore.use.actions(); @@ -52,6 +54,12 @@ export function GitCommitInspector({ }; }, [commit, repoPath]); + useLayoutEffect(() => { + const element = detailsScrollRef.current; + if (!element) return; + return bindScrollContainerWheel(element); + }, []); + return (
-
+
{commit ? (
{commit.message}
diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx index fc76b9f57..3869a7ccc 100644 --- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -6,7 +6,8 @@ import { NetworkIcon, TagIcon, } from "@/ui/icons"; -import { useMemo } from "react"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel"; import { cn } from "@/utils/cn"; import { useTranslation } from "@/i18n/locale-provider"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; @@ -122,6 +123,13 @@ export function GitReferenceTree({ () => new Map(SECTION_KEYS.map(({ kind }) => [kind, buildGitReferenceTree(references, kind)])), [references], ); + const scrollRef = useRef(null); + + useLayoutEffect(() => { + const element = scrollRef.current; + if (!element) return; + return bindScrollContainerWheel(element); + }, []); return (
@@ -129,7 +137,7 @@ export function GitReferenceTree({ {t("git.log.references")} {references.length}
-
+
); diff --git a/windows/tauri/src/features/settings/config/default-settings.ts b/windows/tauri/src/features/settings/config/default-settings.ts index 356f7ba4a..b34287be3 100644 --- a/windows/tauri/src/features/settings/config/default-settings.ts +++ b/windows/tauri/src/features/settings/config/default-settings.ts @@ -21,7 +21,7 @@ const DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL = ""; export const defaultSettings: Settings = { // General - autoSave: false, + autoSave: true, quickOpenPreview: true, // Editor fontFamily: DEFAULT_MONO_FONT_FAMILY, diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts index 9bf0590dc..f4f5f6df6 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts @@ -5,6 +5,12 @@ import { normalizeSettingValue, } from "@/features/settings/lib/settings-normalization"; +describe("default settings", () => { + test("enables auto-save unless the user turns it off", () => { + expect(getDefaultSettingsSnapshot().autoSave).toBe(true); + }); +}); + describe("JDTLS JDK setting normalization", () => { test("defaults to automatic discovery", () => { expect(getDefaultSettingsSnapshot().jdtlsJavaHomePath).toBe(""); From a6ebf93dd405c4c9ff211c617bfec7d895422fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 20 Aug 2026 22:30:31 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=8C=87=E5=87=BA=E7=9A=84=20LSP=20=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E3=80=81waitEvents=20=E7=83=AD=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E6=BB=9A=E8=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步消除诊断面板 ScrollArea 无限更新崩溃,以及查找栏 Shift+Enter 提示闪烁。 Co-authored-by: Cursor --- rust/lithe-core/src/lsp/interface/client.rs | 47 +++-- rust/lithe-core/src/lsp/interface/engine.rs | 56 ++++- rust/lithe-core/src/lsp/lightweight/edits.rs | 78 ++++++- rust/lithe-core/src/lsp/tests.rs | 199 ++++++++++++++++++ .../features/editor/engines/monaco/theme.ts | 6 + .../editor/hooks/use-lsp-integration.ts | 10 +- .../features/editor/styles/monaco-editor.css | 45 ++++ .../tauri/src/platform/lsp-core-adapter.ts | 29 ++- windows/tauri/src/ui/scroll-area.tsx | 21 +- .../src/ui/scroll-container-wheel.test.ts | 80 +++++++ .../tauri/src/ui/scroll-container-wheel.ts | 78 ++++++- 11 files changed, 612 insertions(+), 37 deletions(-) diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 3b3bd4372..8394b2ef2 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1,7 +1,10 @@ //! Pure LSP client-state transitions and JSON-RPC message construction. use super::types::*; -use crate::lsp::{apply_text_edits, ApplyTextEditsRequest, LspTextEdit}; +use crate::lsp::{ + apply_content_changes_sequential, order_incremental_changes_for_replay, ApplyTextEditsRequest, + LspTextEdit, +}; use crate::protocol::{CoreError, ErrorCode}; use serde_json::{json, Value}; @@ -154,22 +157,32 @@ pub fn client_change_document( "Cannot change a document that is not open in the LSP client.", )); }; - let incremental = state.text_document_sync == LspTextDocumentSyncKind::Incremental - && !request.content_changes.is_empty() - && request - .content_changes - .iter() - .all(|change| change.range.is_some()); - let next_text = if incremental { - apply_content_changes(&document.text, &request.content_changes)? - } else if !request.content_changes.is_empty() && request.text.is_empty() { - apply_content_changes(&document.text, &request.content_changes)? + let wants_incremental = state.text_document_sync == LspTextDocumentSyncKind::Incremental + && !request.content_changes.is_empty(); + let ordered_incremental = if wants_incremental { + order_incremental_changes_for_replay(&document.text, &request.content_changes)? + } else { + None + }; + let (next_text, wire_incremental) = if let Some(changes) = ordered_incremental { + ( + apply_content_changes_sequential(&document.text, &changes)?, + Some(changes), + ) + } else if !request.text.is_empty() { + // Host-provided full text, or full-sync fallback after an unsafe batch. + (request.text, None) + } else if !request.content_changes.is_empty() { + // No full text available; derive local document from the ranged batch. + ( + apply_content_changes_as_simultaneous_edits(&document.text, &request.content_changes)?, + None, + ) } else { - request.text + (String::new(), None) }; - let content_changes = if incremental { - json!(request - .content_changes + let content_changes = if let Some(changes) = wire_incremental.as_ref() { + json!(changes .iter() .map(|change| { let range = change.range.expect("incremental changes require a range"); @@ -208,7 +221,7 @@ pub fn client_change_document( Ok(client_response(state, vec![message], Vec::new())) } -fn apply_content_changes( +fn apply_content_changes_as_simultaneous_edits( text: &str, changes: &[LspDocumentContentChange], ) -> Result { @@ -222,7 +235,7 @@ fn apply_content_changes( new_text: change.text.clone(), }); } - Ok(apply_text_edits(ApplyTextEditsRequest { + Ok(crate::lsp::apply_text_edits(ApplyTextEditsRequest { text: text.to_string(), edits, })? diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index d93c3e11b..9cc39a09d 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -957,7 +957,17 @@ impl RuntimeSession { state.lifecycle, LspLifecycleState::Stopped | LspLifecycleState::Failed ) { - return Ok(Vec::new()); + // Returning Ok([]) here would let frontend pumps spin on Core IPC + // forever after the terminal stateChanged event was drained. + let details = match state.lifecycle { + LspLifecycleState::Failed => "sessionFailed", + _ => "sessionStopped", + }; + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Language-server session is no longer running.", + ) + .with_details(details)); } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -3265,6 +3275,50 @@ mod tests { ); } + #[test] + fn wait_events_errors_after_terminal_state_events_are_drained() { + let mut harness = Harness::ready(); + harness.poll(); + harness.session().stop().unwrap(); + let shutdown_id = harness + .server + .await_request("shutdown") + .expect("stop should request shutdown"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": shutdown_id, + "result": null + })); + assert!( + harness.server.await_notification("exit"), + "exit must follow the shutdown response" + ); + harness.server.exit(Some(0)); + harness.await_state(LspLifecycleState::Stopped); + + // Drain any remaining lifecycle events from the stop transition. + loop { + let events = match harness.session().wait_events(Duration::from_millis(20)) { + Ok(events) => events, + Err(error) => { + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(error.details.as_deref(), Some("sessionStopped")); + return; + } + }; + if events.is_empty() { + break; + } + } + + let error = harness + .session() + .wait_events(Duration::from_millis(50)) + .expect_err("drained terminal sessions must not return empty Ok"); + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(error.details.as_deref(), Some("sessionStopped")); + } + #[test] fn java_runtime_is_derived_from_the_start_environment() { let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); diff --git a/rust/lithe-core/src/lsp/lightweight/edits.rs b/rust/lithe-core/src/lsp/lightweight/edits.rs index f9e9d4af7..b0b74b9ca 100644 --- a/rust/lithe-core/src/lsp/lightweight/edits.rs +++ b/rust/lithe-core/src/lsp/lightweight/edits.rs @@ -1,6 +1,8 @@ //! UTF-16-aware text edit validation and application. -use crate::lsp::interface::{LspPosition, LspPositionResponse, LspRange, LspRangeResponse}; +use crate::lsp::interface::{ + LspDocumentContentChange, LspPosition, LspPositionResponse, LspRange, LspRangeResponse, +}; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -61,6 +63,80 @@ pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result Result>, CoreError> { + if changes.is_empty() { + return Ok(Some(Vec::new())); + } + if changes.iter().any(|change| change.range.is_none()) { + return Ok(None); + } + + let mut keyed = Vec::with_capacity(changes.len()); + for change in changes { + let range = change.range.expect("ranges checked above"); + let start = utf16_position_to_byte_offset(text, range.start)?; + let end = utf16_position_to_byte_offset(text, range.end)?; + if end < start { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange")); + } + keyed.push((start, end, change.clone())); + } + + let mut ascending: Vec<(usize, usize)> = + keyed.iter().map(|(start, end, _)| (*start, *end)).collect(); + ascending.sort_by_key(|(start, _)| *start); + for pair in ascending.windows(2) { + if pair[0].1 > pair[1].0 { + return Ok(None); + } + } + + keyed.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start)); + Ok(Some(keyed.into_iter().map(|(_, _, change)| change).collect())) +} + +/// Applies ranged content changes sequentially in the given order. +pub(crate) fn apply_content_changes_sequential( + text: &str, + changes: &[LspDocumentContentChange], +) -> Result { + let mut text = text.to_string(); + for change in changes { + let Some(range) = change.range else { + text = change.text.clone(); + continue; + }; + let start = utf16_position_to_byte_offset(&text, range.start)?; + let end = utf16_position_to_byte_offset(&text, range.end)?; + if end < start { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange")); + } + text.replace_range(start..end, &change.text); + } + Ok(text) +} + pub(super) fn utf16_position_to_byte_offset( text: &str, position: LspPosition, diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 42ef2bf40..29deb2b12 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -681,6 +681,205 @@ fn client_change_document_emits_incremental_ranges_when_the_server_supports_them ); } +#[test] +fn client_change_document_replays_multi_change_batches_end_to_start() { + // Pre-event document. Both Monaco ranges are relative to this text: + // insert "X" at column 1, and insert a newline at column 3 (between "c" and "d"). + // Sequential LSP apply in original left-to-right order would shift the + // second range and diverge; end-to-start order keeps Core and wire aligned. + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/multi.rs".to_string(), + language_id: "rust".to_string(), + text: "abcd".to_string(), + }) + .unwrap(); + let mut state = opened.state; + state.text_document_sync = LspTextDocumentSyncKind::Incremental; + let changed = client_change_document(ClientChangeDocumentRequest { + state, + uri: "file:///tmp/project/multi.rs".to_string(), + text: String::new(), + content_changes: vec![ + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 1, + }, + end: LspPosition { + line: 0, + utf16_column: 1, + }, + }), + text: "X".to_string(), + }, + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 3, + }, + end: LspPosition { + line: 0, + utf16_column: 3, + }, + }), + text: "\n".to_string(), + }, + ], + }) + .unwrap(); + + let document = changed + .state + .open_documents + .get("file:///tmp/project/multi.rs") + .unwrap(); + assert_eq!(document.text, "aXbc\nd"); + + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + let content_changes = did_change["params"]["contentChanges"].as_array().unwrap(); + assert_eq!(content_changes.len(), 2); + // Wire order must be end-to-start so sequential replay matches document.text. + assert_eq!(content_changes[0]["text"], "\n"); + assert_eq!(content_changes[0]["range"]["start"]["character"], 3); + assert_eq!(content_changes[1]["text"], "X"); + assert_eq!(content_changes[1]["range"]["start"]["character"], 1); + + let replayed = apply_content_changes_sequential( + "abcd", + &[ + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 3, + }, + end: LspPosition { + line: 0, + utf16_column: 3, + }, + }), + text: "\n".to_string(), + }, + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 1, + }, + end: LspPosition { + line: 0, + utf16_column: 1, + }, + }), + text: "X".to_string(), + }, + ], + ) + .unwrap(); + assert_eq!(replayed, document.text); + + // Left-to-right sequential apply of the original Monaco order diverges, + // which is exactly the bug the end-to-start wire order prevents. + let diverged = apply_content_changes_sequential( + "abcd", + &[ + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 1, + }, + end: LspPosition { + line: 0, + utf16_column: 1, + }, + }), + text: "X".to_string(), + }, + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 3, + }, + end: LspPosition { + line: 0, + utf16_column: 3, + }, + }), + text: "\n".to_string(), + }, + ], + ) + .unwrap(); + assert_ne!(diverged, document.text); +} + +#[test] +fn client_change_document_falls_back_to_full_sync_for_overlapping_ranges() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/overlap.rs".to_string(), + language_id: "rust".to_string(), + text: "abcdef".to_string(), + }) + .unwrap(); + let mut state = opened.state; + state.text_document_sync = LspTextDocumentSyncKind::Incremental; + let changed = client_change_document(ClientChangeDocumentRequest { + state, + uri: "file:///tmp/project/overlap.rs".to_string(), + text: "Z".to_string(), + content_changes: vec![ + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 3, + }, + }), + text: "AA".to_string(), + }, + LspDocumentContentChange { + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 2, + }, + end: LspPosition { + line: 0, + utf16_column: 5, + }, + }), + text: "BB".to_string(), + }, + ], + }) + .unwrap(); + + assert_eq!( + changed + .state + .open_documents + .get("file:///tmp/project/overlap.rs") + .unwrap() + .text, + "Z" + ); + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + assert_eq!( + did_change["params"]["contentChanges"], + json!([{ "text": "Z" }]) + ); +} + #[test] fn client_core_closes_open_documents() { let uri = "file:///tmp/project/main.go"; diff --git a/windows/tauri/src/features/editor/engines/monaco/theme.ts b/windows/tauri/src/features/editor/engines/monaco/theme.ts index 0e5daf283..28b2c3bc7 100644 --- a/windows/tauri/src/features/editor/engines/monaco/theme.ts +++ b/windows/tauri/src/features/editor/engines/monaco/theme.ts @@ -89,6 +89,12 @@ function createMonacoThemeData( "editorWidget.foreground": foreground, "editorWidget.border": border, "editorWidget.resizeBorder": accent, + // Find/replace button tooltips (Previous Match / Shift+Enter, etc.) use + // workbench hover CSS vars derived from these theme colors. + "editorHoverWidget.background": secondaryBackground, + "editorHoverWidget.foreground": foreground, + "editorHoverWidget.border": border, + "editorHoverWidget.statusBarBackground": selected, "editorSuggestWidget.background": background, "editorSuggestWidget.foreground": foreground, "editorSuggestWidget.border": border, diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 3f968e3f1..e24c10993 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -116,7 +116,15 @@ export const useLspIntegration = ({ const notify = preferIncremental && contentChanges.length > 0 - ? lspClient.notifyDocumentChange(filePath, undefined, newVersion, contentChanges) + ? // Always include the latest full text so Core can fall back to a + // full-document didChange when a multi-change batch is unsafe to + // replay incrementally (overlap / missing ranges). + lspClient.notifyDocumentChange( + filePath, + documentTextForPath(filePath), + newVersion, + contentChanges, + ) : lspClient.notifyDocumentChange(filePath, documentTextForPath(filePath), newVersion); notify.catch((error) => { diff --git a/windows/tauri/src/features/editor/styles/monaco-editor.css b/windows/tauri/src/features/editor/styles/monaco-editor.css index c2e0010b5..4dca86aa5 100644 --- a/windows/tauri/src/features/editor/styles/monaco-editor.css +++ b/windows/tauri/src/features/editor/styles/monaco-editor.css @@ -1,5 +1,50 @@ .monaco-editor-shell { --vscode-editor-background: var(--background, #0f1117); + --vscode-editorWidget-background: color-mix(in srgb, var(--surface, #1c1b19) 94%, var(--background, #0f1117)); + --vscode-editorWidget-foreground: var(--foreground, #faf9f5); + --vscode-editorWidget-border: color-mix(in srgb, var(--border, rgba(255, 255, 255, 0.12)) 78%, transparent); + --vscode-editorHoverWidget-background: color-mix( + in srgb, + var(--surface, #1c1b19) 96%, + var(--background, #0f1117) + ); + --vscode-editorHoverWidget-foreground: var(--foreground, #faf9f5); + --vscode-editorHoverWidget-border: var(--border, rgba(255, 255, 255, 0.12)); + --vscode-widget-shadow: color-mix(in srgb, black 28%, transparent); +} + +/* + * Find-widget button tooltips (e.g. "Previous Match (Shift+Enter)") render in a + * context-view container outside .monaco-editor-shell. Without opaque styles the + * label sits on top of the find bar and hideOnHover causes show/hide flicker. + */ +.monaco-hover.workbench-hover { + border: 1px solid var(--border, rgba(255, 255, 255, 0.12)) !important; + border-radius: var(--radius-md, 8px) !important; + background: color-mix( + in srgb, + var(--surface, #1c1b19) 96%, + var(--background, #0f1117) + ) !important; + color: var(--foreground, #faf9f5) !important; + box-shadow: var(--shadow-popover) !important; + font-family: var(--app-font-family); +} + +.monaco-hover.workbench-hover.compact .hover-contents { + color: var(--foreground, #faf9f5) !important; + font-family: var(--app-font-family); + font-size: var(--ui-text-sm); +} + +.workbench-hover-pointer:after { + background-color: color-mix( + in srgb, + var(--surface, #1c1b19) 96%, + var(--background, #0f1117) + ) !important; + border-right-color: var(--border, rgba(255, 255, 255, 0.12)) !important; + border-bottom-color: var(--border, rgba(255, 255, 255, 0.12)) !important; } .monaco-editor-shell .monaco-editor, diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 42257b471..533ef4731 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -32,8 +32,12 @@ const fileSessions = new Map(); function coreData(response: CoreResponse): T { if (response.ok) return response.data; - const error = new Error(response.error.message) as Error & { code?: string }; + const error = new Error(response.error.message) as Error & { + code?: string; + details?: string; + }; error.code = response.error.code; + error.details = response.error.details; throw error; } @@ -79,6 +83,15 @@ async function dispatchRuntimeEvent(event: RuntimeEvent): Promise { async function dispatchSessionEvent(session: Session, event: RuntimeEvent): Promise { await dispatchRuntimeEvent(event); + if (event.type === "stateChanged" && (event.state === "failed" || event.state === "stopped")) { + const error = new Error(`Language server entered ${event.state} state`) as Error & { + code?: string; + }; + error.code = event.state === "failed" ? "sessionFailed" : "sessionStopped"; + for (const pending of session.pending.values()) pending.reject(error); + session.pending.clear(); + session.running = false; + } if (event.type !== "requestCompleted" || !event.operationId) return; const pending = session.pending.get(event.operationId); if (!pending) { @@ -116,7 +129,12 @@ async function runEventPump(session: Session): Promise { for (const pending of session.pending.values()) pending.reject(error); session.pending.clear(); session.running = false; - await emit("lsp://server-crashed", {}); + const details = String((error as Error & { details?: string }).details ?? ""); + // waitEvents returns process_failed/sessionStopped after an intentional + // stop; only unexpected failures (and sessionFailed) should crash UI. + if (details !== "sessionStopped") { + await emit("lsp://server-crashed", {}); + } return; } } @@ -375,9 +393,10 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom sessionId: session.id, uri: fileUri(args.filePath), languageId: args.languageId ?? session.languageId, - ...(contentChanges.length > 0 - ? { contentChanges } - : { text: args.content ?? "" }), + // Prefer ranged changes when present, but keep full text so Core can + // fall back to a full-document didChange for unsafe multi-change batches. + text: args.content ?? "", + ...(contentChanges.length > 0 ? { contentChanges } : {}), }); return undefined as T; } diff --git a/windows/tauri/src/ui/scroll-area.tsx b/windows/tauri/src/ui/scroll-area.tsx index 429032ce1..38c07332e 100644 --- a/windows/tauri/src/ui/scroll-area.tsx +++ b/windows/tauri/src/ui/scroll-area.tsx @@ -1,5 +1,5 @@ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; -import { useLayoutEffect, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import type * as React from "react"; import { bindOverlayWheelToScrollContainer, @@ -33,6 +33,8 @@ function ScrollArea({ ...props }: ScrollAreaProps) { const { ref: viewportRef, style: viewportStyle, ...resolvedViewportProps } = viewportProps ?? {}; + const viewportRefProp = useRef(viewportRef); + viewportRefProp.current = viewportRef; const [rootNode, setRootNode] = useState(null); const [viewportNode, setViewportNode] = useState(null); @@ -46,14 +48,17 @@ function ScrollArea({ return bindOverlayWheelToScrollContainer(rootNode, () => viewportNode); }, [rootNode, viewportNode]); - const setViewportRef = (node: HTMLDivElement | null) => { - setViewportNode(node); - if (typeof viewportRef === "function") { - viewportRef(node); - } else if (viewportRef) { - viewportRef.current = node; + // Keep this callback identity stable. Base UI forks refs; a new function each + // render retriggers cleanup(null)/attach(node) and can infinite-loop setState. + const setViewportRef = useCallback((node: HTMLDivElement | null) => { + setViewportNode((current) => (current === node ? current : node)); + const forwarded = viewportRefProp.current; + if (typeof forwarded === "function") { + forwarded(node); + } else if (forwarded) { + forwarded.current = node; } - }; + }, []); return ( { @@ -60,4 +63,81 @@ describe("scroll container wheel", () => { expect(applyVerticalWheelToScrollContainer(element, 40)).toBe(false); expect(element.scrollTop).toBe(600); }); + + test("defers to a nested textarea/overflow scroller that can still move", () => { + const nested = { + scrollTop: 10, + scrollHeight: 400, + clientHeight: 100, + }; + const outer = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + + expect(canScrollVerticallyInDirection(nested, 40)).toBe(true); + expect( + resolveWheelScrollChainTarget({ + nestedCanScrollInDirection: canScrollVerticallyInDirection(nested, 40), + outerCanScrollInDirection: canScrollVerticallyInDirection(outer, 40), + }), + ).toBe("nested"); + }); + + test("scrolls the outer container when the nested scroller is at its boundary", () => { + const nested = { + scrollTop: 300, + scrollHeight: 400, + clientHeight: 100, + }; + const outer = { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 400, + }; + + expect(canScrollVerticallyInDirection(nested, 40)).toBe(false); + expect(canScrollVerticallyInDirection(outer, 40)).toBe(true); + expect( + resolveWheelScrollChainTarget({ + nestedCanScrollInDirection: canScrollVerticallyInDirection(nested, 40), + outerCanScrollInDirection: canScrollVerticallyInDirection(outer, 40), + }), + ).toBe("outer"); + }); + + test("does not capture the wheel when neither nested nor outer can scroll", () => { + expect( + resolveWheelScrollChainTarget({ + nestedCanScrollInDirection: false, + outerCanScrollInDirection: false, + }), + ).toBe("none"); + }); + + test("composedPath prefers the nearest nested textarea that can still scroll", () => { + expect( + findNestedScrollableInComposedPath({ + outerId: "settings-panel", + path: [ + { id: "textarea", scrollable: true, canScrollInDirection: true }, + { id: "form-row", scrollable: false, canScrollInDirection: false }, + { id: "settings-panel", scrollable: true, canScrollInDirection: true }, + ], + }), + ).toBe("textarea"); + }); + + test("composedPath skips a nested scroller that is already at its boundary", () => { + expect( + findNestedScrollableInComposedPath({ + outerId: "settings-panel", + path: [ + { id: "textarea", scrollable: true, canScrollInDirection: false }, + { id: "settings-panel", scrollable: true, canScrollInDirection: true }, + ], + }), + ).toBeNull(); + }); }); diff --git a/windows/tauri/src/ui/scroll-container-wheel.ts b/windows/tauri/src/ui/scroll-container-wheel.ts index be5fe2d07..9411bd709 100644 --- a/windows/tauri/src/ui/scroll-container-wheel.ts +++ b/windows/tauri/src/ui/scroll-container-wheel.ts @@ -47,25 +47,88 @@ export function getWheelDeltaPixels(event: WheelDeltaEvent, metrics: WheelDeltaM return { x: event.deltaX, y: event.deltaY }; } -export function applyVerticalWheelToScrollContainer( +export function canScrollVerticallyInDirection( element: VerticalScrollContainer, deltaY: number, ) { if (deltaY === 0) return false; const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); - const nextScrollTop = Math.max(0, Math.min(maxScrollTop, element.scrollTop + deltaY)); - if (nextScrollTop === element.scrollTop) return false; + if (maxScrollTop <= 0) return false; + if (deltaY < 0) return element.scrollTop > 0; + return element.scrollTop < maxScrollTop; +} + +export function applyVerticalWheelToScrollContainer( + element: VerticalScrollContainer, + deltaY: number, +) { + if (!canScrollVerticallyInDirection(element, deltaY)) return false; - element.scrollTop = nextScrollTop; + const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); + element.scrollTop = Math.max(0, Math.min(maxScrollTop, element.scrollTop + deltaY)); return true; } +/** + * Decide whether a capture-phase outer scroller should yield to a nested + * scrollable (textarea / overflow container) that can still move in `deltaY`. + */ +export function resolveWheelScrollChainTarget(args: { + nestedCanScrollInDirection: boolean; + outerCanScrollInDirection: boolean; +}): "nested" | "outer" | "none" { + if (args.nestedCanScrollInDirection) return "nested"; + if (args.outerCanScrollInDirection) return "outer"; + return "none"; +} + +/** Pure composedPath walk used by tests and mirrored by the DOM helper below. */ +export function findNestedScrollableInComposedPath(args: { + path: Array<{ id: string; scrollable: boolean; canScrollInDirection: boolean }>; + outerId: string; +}): string | null { + for (const node of args.path) { + if (node.id === args.outerId) break; + if (!node.scrollable) continue; + if (node.canScrollInDirection) return node.id; + } + return null; +} + function getLineHeight(element: HTMLElement) { const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight); return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 16; } +function isVerticallyScrollableElement(element: HTMLElement) { + if (element instanceof HTMLTextAreaElement) { + return element.scrollHeight > element.clientHeight; + } + + const overflowY = getComputedStyle(element).overflowY; + if (overflowY !== "auto" && overflowY !== "scroll" && overflowY !== "overlay") { + return false; + } + return element.scrollHeight > element.clientHeight; +} + +function findNestedVerticalScrollTarget( + outer: HTMLElement, + event: WheelEvent, + deltaY: number, +): HTMLElement | null { + const path = typeof event.composedPath === "function" ? event.composedPath() : []; + for (const node of path) { + if (!(node instanceof HTMLElement)) continue; + if (node === outer) break; + if (!outer.contains(node)) continue; + if (!isVerticallyScrollableElement(node)) continue; + if (canScrollVerticallyInDirection(node, deltaY)) return node; + } + return null; +} + function applyVerticalWheelEvent(element: HTMLElement, event: WheelEvent) { if (event.ctrlKey || event.metaKey || event.defaultPrevented) return false; if (!isMostlyVerticalWheel(event.deltaX, event.deltaY)) return false; @@ -76,6 +139,13 @@ function applyVerticalWheelEvent(element: HTMLElement, event: WheelEvent) { pageHeight: element.clientHeight, }); + const nested = findNestedVerticalScrollTarget(element, event, delta.y); + const target = resolveWheelScrollChainTarget({ + nestedCanScrollInDirection: nested !== null, + outerCanScrollInDirection: canScrollVerticallyInDirection(element, delta.y), + }); + + if (target === "nested" || target === "none") return false; return applyVerticalWheelToScrollContainer(element, delta.y); } From 751f13a6b29fd8d14bd58b084046cc21976085dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 20 Aug 2026 23:54:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E4=B8=8E=E6=96=87=E4=BB=B6=E6=A0=91=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=8C=BA=E6=BB=9A=E8=BD=AE=E6=97=A0=E6=B3=95=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebView2 会将滚轮默认动作粘在 overflow:hidden/clip 子节点上,且 Base UI ScrollArea 仅在滚动条上处理 wheel。改为由滚动容器主动转发滚轮,并消除设置页双层滚动结构。 Refs: #205 Co-authored-by: Cursor --- .../styles/file-explorer-tree.css | 9 +++++--- .../components/log-settings-panel.tsx | 6 ++--- .../components/macos-settings-panels.tsx | 8 ++++--- .../settings/components/settings-dialog.tsx | 21 ++++++++++++----- windows/tauri/src/ui/dialog.tsx | 23 ++++++++++++++++--- .../src/ui/scroll-container-wheel.test.ts | 17 ++++++++++++++ .../tauri/src/ui/scroll-container-wheel.ts | 19 +++++++++++---- 7 files changed, 81 insertions(+), 22 deletions(-) diff --git a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css index faab5a6bf..3a979e6df 100644 --- a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css +++ b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css @@ -40,7 +40,9 @@ width: 100% !important; min-width: 0 !important; height: 100%; - overflow: clip; + /* Keep overflow visible so WebView2 does not latch wheel onto the row; ellipsis + is handled on the label span below. */ + overflow: visible; display: flex !important; justify-content: flex-start !important; } @@ -73,14 +75,15 @@ height: 100%; max-width: 100%; min-width: 0; - overflow: clip; + overflow: visible; } .file-tree-container .file-tree-row > span:last-child { min-width: 0; flex: 1 1 auto; - overflow: clip; + overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .file-tree-container .file-tree-row input { diff --git a/windows/tauri/src/features/settings/components/log-settings-panel.tsx b/windows/tauri/src/features/settings/components/log-settings-panel.tsx index a449f66f0..fb1ee19f5 100644 --- a/windows/tauri/src/features/settings/components/log-settings-panel.tsx +++ b/windows/tauri/src/features/settings/components/log-settings-panel.tsx @@ -24,11 +24,11 @@ import { writeClipboardText } from "@/utils/clipboard"; function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { return ( -
-

+
+

{title}

-
{children}
+
{children}
); } diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx index b3121e1c1..66da65816 100644 --- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx @@ -26,12 +26,14 @@ const controlClassName = "h-8 rounded-md border border-input bg-background px-2.5 text-foreground outline-none focus:border-primary"; export function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { + // Avoid overflow:hidden/clip here: WebView2 latches wheel onto those boxes and + // blocks scrolling the settings panel. Radius is applied on the chrome pieces. return ( -
-

+
+

{title}

-
{children}
+
{children}
); } diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx index eaef61e89..bdce8b53c 100644 --- a/windows/tauri/src/features/settings/components/settings-dialog.tsx +++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx @@ -62,6 +62,7 @@ const SettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => { const settingsInitialTab = useUIState((state) => state.settingsInitialTab); const [activeCategory, setActiveCategory] = useState("general"); const contentRef = useRef(null); + const navRef = useRef(null); const resetToDefaults = useSettingsStore((state) => state.actions.resetToDefaults); useEffect(() => { @@ -71,9 +72,14 @@ const SettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => { useLayoutEffect(() => { if (!isOpen) return; - const element = contentRef.current; - if (!element) return; - return bindScrollContainerWheel(element); + const content = contentRef.current; + const nav = navRef.current; + const unbindContent = content ? bindScrollContainerWheel(content) : undefined; + const unbindNav = nav ? bindScrollContainerWheel(nav) : undefined; + return () => { + unbindContent?.(); + unbindNav?.(); + }; }, [isOpen]); if (!isOpen) return null; @@ -85,6 +91,7 @@ const SettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => { onClose={onClose} title={t("workbench.settings")} icon={GearIcon} + contentScroll={false} footer={