diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index e2d45fec6..6cef110cb 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -36,12 +36,21 @@ pub enum IdeEvent { base_version: u32, version: u32, changes: Vec, + /// Part of a grouped transaction; the editor must not record a file-local undo. + #[serde(default)] + #[specta(optional)] + workspace_edit: bool, }, /// A document's dirty flag changed (saved / modified externally). DocDirty { doc: DocId, dirty: bool, }, + /// A complete workspace edit can be undone as one operation. + WorkspaceEditApplied { + id: u32, + paths: Vec, + }, /// An open document's language changed (Settings › File Types, Override File /// Type), or its UI mode (a framework's root marker appeared or went). DocLanguage { diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs index 456481283..6e21958dc 100644 --- a/crates/ide-core/src/text.rs +++ b/crates/ide-core/src/text.rs @@ -97,6 +97,16 @@ pub struct TextEdit { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct WorkspaceEdit { pub files: Vec, + /// Content fingerprints captured before presenting an edit for confirmation. + #[serde(default)] + #[specta(optional)] + pub preconditions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WorkspaceEditPrecondition { + pub path: crate::IpcPath, + pub hash: String, } impl WorkspaceEdit { @@ -108,17 +118,26 @@ impl WorkspaceEdit { let mut resolve_aliases = true; for file in std::mem::take(&mut self.files) { match file { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, version } => { let path = if resolve_aliases { dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf()) } else { path.to_path_buf() }; let index = *batch.entry(path.clone()).or_insert_with(|| { - files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() }); + files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version }); files.len() - 1 }); - let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() }; + let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else { + unreachable!() + }; + if expected.is_some() && version.is_some() && *expected != version { + // Keep incompatible constraints so application rejects them + // before changing any file instead of silently losing one. + files.push(FileEdit::Edit { path: path.into(), edits, version }); + continue; + } + *expected = expected.or(version); for edit in edits { if !own.contains(&edit) { own.push(edit); @@ -140,10 +159,26 @@ impl WorkspaceEdit { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileEdit { - Edit { path: crate::IpcPath, edits: Vec }, - Create { path: crate::IpcPath, overwrite: bool }, - Rename { from: crate::IpcPath, to: crate::IpcPath }, - Delete { path: crate::IpcPath, recursive: bool }, + Edit { + path: crate::IpcPath, + edits: Vec, + /// LSP TextDocumentEdit version; null/unversioned edits use content preconditions. + #[serde(default)] + #[specta(optional)] + version: Option, + }, + Create { + path: crate::IpcPath, + overwrite: bool, + }, + Rename { + from: crate::IpcPath, + to: crate::IpcPath, + }, + Delete { + path: crate::IpcPath, + recursive: bool, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] @@ -254,7 +289,7 @@ mod workspace_edit_tests { std::fs::write(&real, "add()").unwrap(); std::os::unix::fs::symlink(&real, &alias).unwrap(); let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }; - let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] }; + let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None }; let mut workspace = WorkspaceEdit { files: vec![ edit(real.clone()), @@ -262,6 +297,7 @@ mod workspace_edit_tests { FileEdit::Create { path: dir.path().join("other").into(), overwrite: false }, edit(real), ], + ..Default::default() }; workspace.deduplicate(); assert_eq!(workspace.files.len(), 3); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index d5fef1e5f..898b2884c 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -174,6 +174,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.agents.shutdown(); project.notebooks.stop_admission(); // What the closing window started goes with it — the builds and searches it @@ -251,6 +252,13 @@ impl Host { ); } + /// Called before desktop exit, which may bypass document destructors. + pub fn clear_workspace_edit_history_on_exit(&self) { + for project in self.projects() { + project.documents.clear_workspace_edit_history(); + } + } + /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. /// For a process that exits without dropping the host (the remote server). pub async fn shutdown(&self) { @@ -262,6 +270,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 767875df5..6ae0b0bd0 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -210,8 +210,10 @@ pub async fn maven_exclude_dependency( let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { Some(Exclusion::Edit(edit)) => { - let edit = - WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + let edit = WorkspaceEdit { + files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }], + ..Default::default() + }; apply_workspace_edit(&project.documents, &edit)?; } Some(Exclusion::Present) => {} diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index aa0f918bb..e1fab8f98 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) { doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); d.add("doc_set_line_ending", |c| async move { - doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) + blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await }); d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await }); d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index b57f54efd..f84ab3c73 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -1,9 +1,33 @@ +use super::doc::blocking; use crate::Host; use crate::apply_workspace_edit as apply_edit; use crate::rpc::Dispatcher; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; +async fn prepare_actions( + project: std::sync::Arc, + doc: DocId, + version: u32, + mut actions: Vec, +) -> IdeResult> { + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing code actions; request them again", + )); + } + for action in &mut actions { + if let Some(edit) = &mut action.edit { + project.documents.prepare_workspace_edit(edit)?; + } + } + Ok(actions) + }) + .await +} + pub async fn lsp_completion( host: &Host, window: &str, @@ -138,7 +162,11 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - host.project(window)?.lsp.code_actions(doc, range, only).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let actions = project.lsp.code_actions(doc, range, only).await?; + prepare_actions(project, doc, version, actions).await } /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). @@ -150,11 +178,15 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.code_action_query(doc, range, only, cancel).await; + let result = project.lsp.code_action_query(doc, range, only, cancel).await; host.end_request(request); - result + let mut result = result?; + result.actions = prepare_actions(project, doc, version, result.actions).await?; + Ok(result) } /// Resolves a code action's edit without applying it (intention preview). @@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action( if action.edit.is_some() { return Ok(action); } - host.project(window)?.lsp.resolve_code_action(doc, action).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0)) } /// Resolves (if needed), applies the edit, then runs the command of a code action. pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> { let project = host.project(window)?; let action = if action.edit.is_none() && action.command.is_none() { - project.lsp.resolve_code_action(doc, action).await? + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0) } else { action }; if let Some(edit) = &action.edit { - apply_edit(&project.documents, edit)?; + let documents = project.documents.clone(); + let edit = edit.clone(); + blocking(move || apply_edit(&documents, &edit)).await?; } if let Some(command) = action.command { project.lsp.execute_command(doc, command).await?; @@ -200,11 +241,24 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.rename(doc, pos, new_name, cancel).await; + let result = project.lsp.rename(doc, pos, new_name, cancel).await; host.end_request(request); - result + let mut renamed = result?; + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing rename; request it again", + )); + } + project.documents.prepare_workspace_edit(&mut renamed.edit)?; + Ok(renamed) + }) + .await } pub async fn lsp_format( @@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. -pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { +pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { let project = host.project(window)?; - // Multi-file refactorings leave a Local History point to go back to. - let paths: Vec<&std::path::Path> = edit - .files - .iter() - .filter_map(|f| match f { - ide_core::FileEdit::Edit { path, .. } => Some(&**path), - _ => None, - }) - .collect(); - if edit.files.len() > 1 { - project.record_before_refactoring(&paths, "Apply changes"); - } - apply_edit(&project.documents, &edit) + blocking(move || { + // Multi-file refactorings leave a Local History point to go back to. + let paths: Vec<&std::path::Path> = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(&**path), + _ => None, + }) + .collect(); + if edit.files.len() > 1 { + project.record_before_refactoring(&paths, "Apply changes"); + } + apply_edit(&project.documents, &edit) + }) + .await +} + +pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) + }) + .await +} + +pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.undo_workspace_edit(id)).await +} + +pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.redo_workspace_edit(id)).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's @@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); - d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }); d.add("lsp_resolve_command", |c| async move { lsp_resolve_command( &c.host, @@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add( + "prepare_workspace_edit", + |c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }, + ); + d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); + d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); d.add("lsp_execute_command", |c| async move { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs index 806f0f3dc..f9a91ad6d 100644 --- a/crates/ide-host/src/ops/refactor.rs +++ b/crates/ide-host/src/ops/refactor.rs @@ -1,7 +1,6 @@ //! File move / rename refactoring: reference updates from the language //! servers, the move itself (one undoable step) and its undo. use crate::Host; -use crate::apply_workspace_edit; use crate::rpc::Dispatcher; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; @@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits( let token = host.begin_request(request); let result = project.lsp.will_rename_files(&pairs(&moves), token).await; host.end_request(request); - result + let mut result = result?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut result.edit)?; + Ok(result) + }) + .await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -42,7 +46,7 @@ pub async fn refactor_move_files( let p = project.clone(); let moved = moves.clone(); let id = blocking(move || { - let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); + let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e); p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) }) .await?; @@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits( let cancel = token.clone(); let result = blocking(move || { let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); - let edit = ide_workspace::text_refs::find_text_occurrences( + let mut edit = ide_workspace::text_refs::find_text_occurrences( &project.root, &project.exclude_globs(), &replacements, &|| cancel.is_cancelled(), ); - if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) }) .await; host.end_request(request); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0e00ae8b9..a42889a19 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -881,37 +881,7 @@ impl Project { /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { - use ide_core::FileEdit; - let mut edit = edit.clone(); - edit.deduplicate(); - for file in &edit.files { - match file { - FileEdit::Edit { path, edits } => { - // Files not open in an editor are opened transiently and closed again. - let (doc, transient) = match documents.by_path(path) { - Some(doc) => (doc, false), - None => (documents.open(path)?.id, true), - }; - let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); - if transient { - documents.close(doc); - } - result?; - } - FileEdit::Create { path, overwrite } => { - if path.exists() { - if *overwrite { - ide_vfs::save_text(path, "", "utf-8", false)?; - } - } else { - ide_vfs::create_file(path)?; - } - } - FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, - FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, - } - } - Ok(()) + documents.apply_workspace_edit(edit) } /// Files the watcher reports per batch that Local History snapshots. @@ -1253,10 +1223,14 @@ mod workspace_edit_tests { let doc = documents.open(&alias).unwrap(); let changes = |path: PathBuf| FileEdit::Edit { path: path.into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], }; - apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] }) - .unwrap(); + apply_workspace_edit( + &documents, + &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() }, + ) + .unwrap(); assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); } diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 8b758d6b4..fb0cc28e9 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -215,9 +215,14 @@ impl Dispatcher { }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) - .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) + .add("doc_close", |c| async move { + ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await + }) .add("doc_edit", |c| async move { - ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + ops::doc::blocking(move || { + ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + }) + .await }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 5fd072bba..d0e0731dd 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -209,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "menu_model" | "menu_configure" | "menu_set_disabled" + | "prepare_workspace_edit" + | "apply_workspace_edit" + | "undo_workspace_edit" + | "redo_workspace_edit" | "lsp_servers" | "lsp_resend_diagnostics" | "recent_projects" diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 2a101b01e..a0ec5004c 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -88,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() { } let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) }; assert!(peer.request::<_, Value>("invoke", call, None).await.is_err()); + // Manual workspace edits remain available in Restricted Mode, like doc/fs + // editing. They do not ask a language server to execute project commands. + let manual = project.join("src/manual.txt"); + std::fs::write(&manual, "old").unwrap(); + let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{ + "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, + "new_text": "new" + }] }] }); + let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap(); + assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1); + invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap(); + until("workspace edit history", || { + client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. })) + }) + .await; + let undo_id = client + .events + .lock() + .iter() + .find_map(|event| match &event.event { + ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old"); + invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false); + for command in ["lsp_execute_command", "lsp_apply_code_action"] { + let error = invoke(&peer, command, json!({})).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}"); + } invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); let current = invoke(&peer, "project_current", json!({})).await.unwrap(); assert_eq!(current["name"], "demo"); diff --git a/crates/ide-host/tests/workspace_edits.rs b/crates/ide-host/tests/workspace_edits.rs new file mode 100644 index 000000000..7bdc5c573 --- /dev/null +++ b/crates/ide-host/tests/workspace_edits.rs @@ -0,0 +1,177 @@ +use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind}; +use ide_host::{Host, HostConfig, ops}; +use parking_lot::Mutex; +use serde_json::json; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let second = tmp.path().join("second.txt"); + for path in [&first, &second] { + std::fs::write(path, "old\n").unwrap(); + } + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |event| events.lock().push(event)) + }; + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| sink.clone()), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_light("main", &first).unwrap(); + let info = ops::doc::open(&host, "main", first.clone()).await.unwrap(); + let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [ + { "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }, + { "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] } + ] })).unwrap(); + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap(); + assert_eq!(preview.preconditions.len(), 2); + std::fs::write(&second, "own\n").unwrap(); + let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap(); + ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + let id = events + .lock() + .iter() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n"); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n"); + ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + host.clear_workspace_edit_history_on_exit(); + assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound); +} + +#[tokio::test(flavor = "current_thread")] +async fn contended_version_reads_do_not_block_the_async_worker() { + use ide_core::{LanguageId, Pos, Range, RequestId}; + use ide_workspace::documents::DocumentObserver; + use std::sync::mpsc; + use std::time::Duration; + + struct PausedWrite { + entered: Mutex>>, + release: Mutex>, + } + impl DocumentObserver for PausedWrite { + fn changed( + &self, + _: ide_core::DocId, + _: &std::path::Path, + _: u32, + _: &[ide_text::AppliedChange], + _: &dyn Fn() -> String, + ) { + if let Some(entered) = self.entered.lock().take() { + let _ = entered.send(()); + self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap(); + } + } + } + + for request_kind in 0..5 { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("main.txt"); + std::fs::write(&file, "old").unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let project = host.open_light("main", &file).unwrap(); + let doc = project.documents.open(&file).unwrap().id; + assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + project.documents.add_observer(Arc::new(PausedWrite { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + })); + let documents = project.documents.clone(); + let writer = std::thread::spawn(move || { + documents + .apply_workspace_edit( + &serde_json::from_value(json!({ "files": [{ + "kind": "edit", "path": file, "edits": [{ "range": { + "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } + }, "new_text": "new" }] + }] })) + .unwrap(), + ) + .unwrap(); + }); + entered_rx.await.unwrap(); + let (heartbeat_tx, heartbeat_rx) = mpsc::channel(); + // Always release the writer, even on the old blocking implementation. + let watchdog = std::thread::spawn(move || { + let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + release_tx.send(()).unwrap(); + responsive + }); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _ = started_tx.send(()); + let action = + || serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap(); + match request_kind { + 0 => { + let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await; + } + 1 => { + let _ = ops::lsp::lsp_code_action_query( + &host, + "main", + RequestId(1), + doc, + Range::point(Pos::new(0, 0)), + vec![], + ) + .await; + } + 2 => { + let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await; + } + 3 => { + let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await; + } + _ => { + let _ = + ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await; + } + } + }); + started_rx.await.unwrap(); + let _ = heartbeat_tx.send(()); + task.await.unwrap(); + writer.join().unwrap(); + assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker"); + } +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 2e29724c1..1071b6957 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { }) .map(|e| conv.text_edit(&path, e)) .collect(); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version }); }; if let Some(changes) = e.document_changes { match changes { @@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { for (uri, edits) in changes { if let Some(path) = uri_path(&uri) { let edits = conv.text_edits(&path, edits); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: None }); } } } - let mut edit = WorkspaceEdit { files }; + let mut edit = WorkspaceEdit { files, ..Default::default() }; edit.deduplicate(); edit } @@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { /// `edit` where they leave its own alone: what only the other server sees /// (Angular templates next to vtsls's TypeScript). pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) { + edit.preconditions.extend(extra.preconditions); let overlap = |a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end); for file in extra.files { - let FileEdit::Edit { path, edits } = file else { + let FileEdit::Edit { path, edits, version } = file else { if !edit.files.contains(&file) { edit.files.push(file); } continue; }; let own = edit.files.iter_mut().find_map(|f| match f { - FileEdit::Edit { path: p, edits } if *p == path => Some(edits), + FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)), _ => None, }); match own { - Some(own) => { + Some((own, own_version)) => { + // Conflicting versions cannot describe one consistent edit. Keep both + // operations so transaction preflight rejects them instead of dropping a guard. + if own_version.is_some() && version.is_some() && *own_version != version { + edit.files.push(FileEdit::Edit { path, edits, version }); + continue; + } + *own_version = own_version.or(version); let added: Vec = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect(); if !added.is_empty() { own.extend(added); own.sort_by_key(|e| e.range.start); } } - None => edit.files.push(FileEdit::Edit { path, edits }), + None => edit.files.push(FileEdit::Edit { path, edits, version }), } } } @@ -1036,16 +1044,20 @@ mod tests { range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)), new_text: text.into(), }; - let file = |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits }; + let file = + |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None }; // vtsls renames a shorthand property with an alias; the Angular server // renames it plainly and adds the template. - let mut merged = - WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] }; + let mut merged = WorkspaceEdit { + files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])], + ..Default::default() + }; let angular = WorkspaceEdit { files: vec![ file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]), file("/a.html", vec![edit(0, 7, "entries")]), ], + ..Default::default() }; merge_workspace_edit(&mut merged, angular); assert_eq!( @@ -1306,7 +1318,7 @@ mod tests { assert_eq!(w.files.len(), 4); assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true }); match &w.files[1] { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, .. } => { assert_eq!(path, Path::new("/n.rs")); assert_eq!(edits.len(), 2); assert_eq!(edits[1].new_text, "y"); @@ -1322,6 +1334,26 @@ mod tests { assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q")); } + #[test] + fn workspace_edits_preserve_document_versions_and_merge_guards() { + let conv = Conv::utf16(); + let incoming = |version| { + serde_json::from_value(json!({ + "documentChanges": [{ + "textDocument": { "uri": "file:///a.rs", "version": version }, + "edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }] + }] + })).unwrap() + }; + let mut edit = workspace_edit(&conv, incoming(Some(7))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8)))); + assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded"); + assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. })); + } + #[test] fn semantic_token_decoding() { let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3]; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index d2136467e..a473e8cbc 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -650,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() { assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 })); let edit = resolved.edit.unwrap(); assert!( - matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0)) + matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0)) ); h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap(); @@ -695,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() { let edits = applied.lock().clone(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false }); - assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X")); + assert!( + matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X") + ); let peer = s.peer(); let cfg: Value = peer @@ -1391,7 +1393,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> { edit.files .iter() .filter_map(|f| match f { - FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| (e.range, e.new_text.clone()))) } _ => None, @@ -1788,7 +1790,7 @@ async fn will_and_did_rename_files_honour_filters() { assert_eq!(result.servers, vec!["mock".to_owned()]); assert!(result.errors.is_empty()); match &result.edit.files[..] { - [FileEdit::Edit { path, edits }] => { + [FileEdit::Edit { path, edits, .. }] => { assert_eq!(path, &main); assert_eq!(edits[0].range, rng(0, 4, 0, 7)); assert_eq!(edits[0].new_text, "lib"); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 3ce021f39..ace064017 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -468,7 +468,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec { .files .iter() .filter_map(|f| match f { - ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| e.range.start.line)) } _ => None, diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index 9036108e6..bf51d7934 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -19,6 +19,7 @@ semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true +tempfile.workspace = true globset.workspace = true [dev-dependencies] diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 2be270353..8ea241afb 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -4,7 +4,7 @@ use ide_core::error::ErrorKind; use ide_core::event::NotifyLevel; use ide_core::{ChangeSet, DocId, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink, TextEdit}; use ide_text::{AppliedChange, ByteEdit, Document, LineEnding}; -use parking_lot::{Mutex, RwLock}; +use parking_lot::{Mutex, ReentrantMutex, RwLock}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; @@ -62,6 +62,11 @@ pub type DetectUiMode = dyn Fn(&Path, &LanguageId) -> Option + Send + Sy /// Every document has its own lock; operations on one document are serialized /// and observers see them in version order. pub struct Documents { + /// Workspace transactions hold this across preflight/commit/rollback; ordinary + /// operations re-enter it while accessing the individual document locks. + operations: ReentrantMutex<()>, + workspace_edits: Arc>, + workspace_edit_active: std::sync::atomic::AtomicBool, registry: Mutex, observers: RwLock>>, sink: SharedSink, @@ -96,6 +101,8 @@ pub trait DocumentObserver: Send + Sync { fn closed(&self, _doc: DocId, _path: &Path) {} } +mod workspace_edit; + type Slot = Arc>; #[derive(Default)] @@ -111,6 +118,7 @@ struct RegEntry { keys: Vec, } +#[derive(Clone)] struct DocState { path: PathBuf, doc: Document, @@ -163,6 +171,9 @@ fn read_only_error(path: &Path) -> IdeError { impl Documents { pub fn new(sink: SharedSink, detect_language: Box) -> Self { Self { + operations: ReentrantMutex::new(()), + workspace_edits: Arc::default(), + workspace_edit_active: std::sync::atomic::AtomicBool::new(false), registry: Mutex::default(), observers: RwLock::default(), sink, @@ -279,6 +290,7 @@ impl Documents { /// Locks a document, failing if it was closed concurrently. fn with_doc(&self, doc: DocId, f: impl FnOnce(&mut DocState) -> IdeResult) -> IdeResult { + let _operation = self.operations.lock(); let slot = self.slot(doc)?; let mut st = slot.lock(); if st.closed { @@ -326,6 +338,7 @@ impl Documents { /// Opens (or re-references) the document at `path`. pub fn open(&self, path: &Path) -> IdeResult { + let _operation = self.operations.lock(); if let Some(info) = self.reopen(path) { return Ok(info); } @@ -418,6 +431,7 @@ impl Documents { /// language server provides for a non-`file:` URI. It is keyed by the URI, /// never touches the disk and cannot be edited or saved. pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult { + let _operation = self.operations.lock(); let key = PathBuf::from(uri); if let Some(info) = self.reopen(&key) { return Ok(info); @@ -476,6 +490,7 @@ impl Documents { } pub fn close(&self, doc: DocId) { + let _operation = self.operations.lock(); let slot = { let mut reg = self.registry.lock(); let Some(entry) = reg.docs.get_mut(&doc) else { return }; @@ -558,7 +573,13 @@ impl Documents { edits: &[ByteEdit], ) { self.notify_changed(doc, st, applied, edits); - self.sink.emit(IdeEvent::DocPatched { doc, base_version, version: st.doc.version(), changes: set.changes }); + self.sink.emit(IdeEvent::DocPatched { + doc, + base_version, + version: st.doc.version(), + changes: set.changes, + workspace_edit: self.workspace_edit_active.load(std::sync::atomic::Ordering::Relaxed), + }); } /// Minimal-diff replacement; no event when the text is unchanged. @@ -693,6 +714,11 @@ impl Documents { self.with_doc(doc, |st| Ok(st.dirty)) } + /// Current generation without copying the document text. + pub fn version(&self, doc: DocId) -> IdeResult { + self.with_doc(doc, |st| Ok(st.doc.version())) + } + /// Ids of all open documents. pub fn open_documents(&self) -> Vec { self.registry.lock().docs.keys().copied().collect() @@ -705,6 +731,7 @@ impl Documents { /// for the observers, so it moves to the matching language servers and /// syntax tree. Returns the changed documents. pub fn redetect_languages(&self) -> Vec { + let _operation = self.operations.lock(); let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect(); let mut changed = Vec::new(); for (id, slot) in slots { diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs new file mode 100644 index 000000000..466987087 --- /dev/null +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -0,0 +1,1187 @@ +//! Preflight, rollback and grouped undo for edits spanning documents and files. + +use super::{DocState, Documents, RegEntry, disk_len, keys_for}; +use ide_core::error::ErrorKind; +use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, NullSink, WorkspaceEdit, WorkspaceEditPrecondition}; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Once}; +use std::time::{Duration, SystemTime}; + +const MAX_HISTORY_BYTES: u64 = 256 * 1024 * 1024; +const MAX_HISTORY_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const SNAPSHOT_PREFIX: &str = "openidea-workspace-edit-"; + +struct ActiveEdit<'a>(&'a AtomicBool); +impl<'a> ActiveEdit<'a> { + fn new(active: &'a AtomicBool) -> Self { + active.store(true, Ordering::Relaxed); + Self(active) + } +} +impl Drop for ActiveEdit<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Relaxed); + } +} + +pub(super) struct History { + next: u32, + reaper: bool, + records: VecDeque<(u32, Record)>, +} + +impl Default for History { + fn default() -> Self { + static CLEANUP: Once = Once::new(); + CLEANUP.call_once(|| cleanup_abandoned(&std::env::temp_dir())); + Self { next: 0, reaper: false, records: VecDeque::new() } + } +} + +impl History { + fn prune(&mut self, now: SystemTime, max_bytes: u64, max_age: Duration) { + self.records.retain(|(_, record)| now.duration_since(record.created).unwrap_or_default() < max_age); + let mut bytes: u64 = self.records.iter().map(|(_, record)| record.bytes).sum(); + while self.records.len() > 20 || bytes > max_bytes { + let Some((_, record)) = self.records.pop_front() else { break }; + bytes = bytes.saturating_sub(record.bytes); + } + } +} + +/// A locked owner file distinguishes live snapshots from abandoned undo/staging +/// data. Unknown legacy directories may contain recovery data, so leave them alone. +fn cleanup_abandoned(root: &Path) { + let Ok(entries) = fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if !entry.file_name().to_string_lossy().starts_with(SNAPSHOT_PREFIX) + || !entry.file_type().is_ok_and(|kind| kind.is_dir()) + || path.join("recovery").exists() + { + continue; + } + let Ok(owner) = fs::OpenOptions::new().read(true).write(true).open(path.join("owner.lock")) else { continue }; + if owner.try_lock().is_ok() && !path.join("recovery").exists() { + // Windows cannot remove an open file. Once unlocked the abandoned + // directory cannot be acquired by a new transaction (names are unique). + drop(owner); + let _ = fs::remove_dir_all(path); + } + } +} + +fn snapshot_bytes(path: &Path) -> IdeResult { + let meta = fs::symlink_metadata(path)?; + if meta.is_dir() { + fs::read_dir(path)?.try_fold(0u64, |bytes, entry| Ok(bytes.saturating_add(snapshot_bytes(&entry?.path())?))) + } else { + Ok(meta.len()) + } +} + +struct SavedPath { + path: PathBuf, + backup: Option, +} + +struct Record { + // Drop the lock before TempDir removes the directory on Windows. + _owner: fs::File, + directory: tempfile::TempDir, + created: SystemTime, + bytes: u64, + undone: bool, + paths: Vec, + documents: Vec<(DocId, DocState)>, + after: Vec, +} + +fn stale(path: &Path) -> IdeError { + IdeError::new(ErrorKind::OutOfSync, format!("{} changed; request the refactoring again", path.display())) +} + +/// Absolute lexical spelling, without resolving links that resource operations may replace. +fn lexical_path(path: &Path) -> IdeResult { + let path = if path.is_absolute() { path.to_owned() } else { std::env::current_dir()?.join(path) }; + let mut clean = PathBuf::new(); + for part in path.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + clean.pop(); + } + other => clean.push(other), + } + } + Ok(clean) +} + +/// Resolve parent aliases while retaining the last component of resource operations. +fn normalized(path: &Path) -> IdeResult { + let clean = lexical_path(path)?; + let Some(name) = clean.file_name() else { return Ok(clean) }; + let parent = clean.parent().expect("absolute path"); + let parent = dunce::canonicalize(parent).map_err(IdeError::from).or_else(|_| normalized(parent))?; + Ok(parent.join(name)) +} + +fn text_path(path: &Path) -> IdeResult { + dunce::canonicalize(path).map_err(IdeError::from).or_else(|_| normalized(path)) +} + +/// Resolve existing parent links one component at a time. Stop before entering +/// any resource operation's namespace, even through another spelling or link chain. +fn after_resources(path: &Path, changed: &[(PathBuf, PathBuf)], links: u32) -> IdeResult { + if links > 40 { + return Err(IdeError::invalid("too many symbolic links in workspace edit")); + } + let path = lexical_path(path)?; + let parts: Vec<_> = path.components().collect(); + let mut current = PathBuf::new(); + for (index, part) in parts.iter().enumerate() { + current.push(part); + if let Some((_, resolved)) = changed.iter().find(|(original, _)| *original == current) { + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return Ok(if suffix.as_os_str().is_empty() { resolved.clone() } else { resolved.join(suffix) }); + } + if index + 1 == parts.len() || !matches!(part, std::path::Component::Normal(_)) { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + let target = fs::read_link(¤t)?; + let target = if target.is_absolute() { target } else { current.parent().expect("parent").join(target) }; + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return after_resources(&target.join(suffix), changed, links + 1); + } + Err(error) if !matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => { + return Err(error.into()); + } + _ => {} + } + if let Ok(canonical) = dunce::canonicalize(¤t) { + current = canonical; + } + } + Ok(current) +} + +fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult { + // Later paths below a resource operation refer to its future filesystem + // state. Preserve that suffix, including links in the old directory tree. + let mut changed: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut files = Vec::new(); + for file in &edit.files { + let resolve = |path: &Path, text: bool| -> IdeResult { + if !changed.is_empty() { + return after_resources(path, &changed, 0); + } + if text { text_path(path) } else { normalized(path) } + }; + let next = match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: resolve(path, true)?.into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: resolve(path, false)?.into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => { + FileEdit::Rename { from: resolve(from, false)?.into(), to: resolve(to, false)?.into() } + } + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: resolve(path, false)?.into(), recursive: *recursive } + } + }; + let resources: Vec<_> = match (file, &next) { + (FileEdit::Rename { from, to }, FileEdit::Rename { from: new_from, to: new_to }) => { + vec![(from, new_from), (to, new_to)] + } + (FileEdit::Create { path, .. }, FileEdit::Create { path: new, .. }) + | (FileEdit::Delete { path, .. }, FileEdit::Delete { path: new, .. }) => vec![(path, new)], + _ => Vec::new(), + }; + for (original, resolved) in resources { + changed.push((lexical_path(original)?, resolved.to_path_buf())); + changed.push((resolved.to_path_buf(), resolved.to_path_buf())); + } + files.push(next); + } + Ok(WorkspaceEdit { files, preconditions: edit.preconditions.clone() }) +} + +fn paths(edit: &WorkspaceEdit) -> IdeResult> { + let mut paths = Vec::new(); + for file in &edit.files { + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + paths.push(path.to_path_buf()) + } + FileEdit::Rename { from, to } => { + if to != from && to.starts_with(from) { + return Err(IdeError::invalid("cannot move a directory into itself")); + } + paths.extend([from.to_path_buf(), to.to_path_buf()]); + } + } + } + // Include missing parents so rollback/undo also removes directories we create. + for path in &mut paths { + while let Some(parent) = path.parent() { + match fs::symlink_metadata(parent) { + Ok(_) => break, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => *path = parent.to_owned(), + Err(e) => return Err(e.into()), + } + } + if path.parent().is_none() { + return Err(IdeError::invalid("cannot refactor a filesystem root")); + } + } + paths.sort(); + paths.dedup(); + let mut roots: Vec = Vec::new(); + for path in paths { + if !roots.iter().any(|p| path.starts_with(p)) { + roots.push(path); + } + } + Ok(roots) +} + +fn hash_bytes(hash: &mut blake3::Hasher, bytes: &[u8]) { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); +} + +fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + hash.update(b"missing"); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + hash.update(&[u8::from(meta.permissions().readonly())]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + hash.update(&meta.permissions().mode().to_le_bytes()); + } + if meta.file_type().is_symlink() { + hash.update(b"link"); + hash_bytes(hash, fs::read_link(path)?.as_os_str().as_encoded_bytes()); + } else if meta.is_file() { + hash.update(b"file"); + hash.update(&meta.len().to_le_bytes()); + let mut file = fs::File::open(path)?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + } else if meta.is_dir() { + hash.update(b"directory"); + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + hash_bytes(hash, entry.file_name().as_encoded_bytes()); + hash_disk(hash, &entry.path())?; + } + } else { + return Err(IdeError::invalid(format!("{} is not a regular file or directory", path.display()))); + } + Ok(()) +} + +/// A reverted edit may leave the dirty flag set even when all saved bytes match. +fn differs_from_disk(state: &DocState) -> IdeResult { + let text = state.doc.text_for_save(); + let bytes = if state.native_to_ascii { + ide_vfs::encode_properties_ascii(&text).into_bytes() + } else { + ide_vfs::encode(&text, &state.encoding, state.has_bom)? + }; + match fs::read(&state.path) { + Ok(disk) => Ok(disk != bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error.into()), + } +} + +impl Documents { + /// Capture disk and unsaved-buffer contents before returning an edit for preview. + /// Existing preconditions must survive preview filtering and code-action resolution. + pub fn prepare_workspace_edit(&self, edit: &mut WorkspaceEdit) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.preconditions.is_empty() { + edit.preconditions = self.fingerprints(&paths(&normalize_edit(edit)?)?)?; + } + Ok(()) + } + + fn fingerprints(&self, paths: &[PathBuf]) -> IdeResult> { + paths + .iter() + .map(|path| { + let mut hash = blake3::Hasher::new(); + hash_disk(&mut hash, path)?; + let mut dirty = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + let file = text_path(&state.path)?; + if state.dirty && file.starts_with(path) && differs_from_disk(state)? { + dirty.push(( + file, + state.doc.text(), + state.encoding.clone(), + state.doc.line_ending(), + state.has_bom, + )); + } + Ok(()) + })?; + } + dirty.sort_by(|a, b| a.0.cmp(&b.0)); + for (file, text, encoding, ending, bom) in dirty { + hash_bytes(&mut hash, file.as_os_str().as_encoded_bytes()); + hash_bytes(&mut hash, text.as_bytes()); + hash_bytes(&mut hash, encoding.as_bytes()); + hash_bytes(&mut hash, ending.as_str().as_bytes()); + hash.update(&[u8::from(bom)]); + } + Ok(WorkspaceEditPrecondition { path: path.clone().into(), hash: hash.finalize().to_hex().to_string() }) + }) + .collect() + } + + fn validate_preconditions(&self, expected: &[WorkspaceEditPrecondition]) -> IdeResult<()> { + let current = self.fingerprints(&expected.iter().map(|p| p.path.to_path_buf()).collect::>())?; + for (before, after) in expected.iter().zip(current) { + if before.hash != after.hash { + return Err(stale(&before.path)); + } + } + Ok(()) + } + + /// Preflight every operation on a private copy, then apply with rollback and grouped undo. + pub fn apply_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(())) + } + + /// File moves already own a combined move/reference undo record. + pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(())) + } + + fn apply_workspace_edit_inner( + &self, + edit: &WorkspaceEdit, + remember: bool, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.files.is_empty() { + return Ok(()); + } + let mut edit = normalize_edit(edit)?; + self.prepare_workspace_edit(&mut edit)?; + self.validate_preconditions(&edit.preconditions)?; + for file in &edit.files { + if let FileEdit::Edit { path, version: Some(version), .. } = file { + let doc = self.by_path(path).ok_or_else(|| stale(path))?; + if i64::from(self.version(doc)?) != i64::from(*version) { + return Err(stale(path)); + } + } + } + edit.deduplicate(); + let roots = paths(&edit)?; + // The watcher may not have delivered an external write yet. Never save + // an edit over disk contents that differ from a supposedly clean buffer. + for id in self.open_documents() { + self.with_doc(id, |state| { + if !state.dirty + && roots.iter().any(|root| text_path(&state.path).is_ok_and(|path| path.starts_with(root))) + { + let loaded = ide_vfs::load_text_as(&state.path, &state.encoding)?; + if ide_text::normalize_line_endings(&loaded.text) != state.doc.text() { + return Err(stale(&state.path)); + } + } + Ok(()) + })?; + } + self.workspace_edits.lock().prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let mut record = self.backup(&roots)?; + self.preflight(&record, &edit)?; + fs::remove_dir_all(record.directory.path().join("stage"))?; + self.validate_preconditions(&edit.preconditions)?; + // A crash during commit can still need these files. Only a completed + // commit turns this into an expendable undo snapshot. + fs::write(record.directory.path().join("recovery"), b"commit in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.apply_sequence(&edit, before_operation) { + return Err(self.recover(record, error)); + } + record.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(record, error)), + }; + if let Err(error) = fs::remove_file(record.directory.path().join("recovery")) { + return Err(self.recover(record, error.into())); + } + if remember { + let mut history = self.workspace_edits.lock(); + history.next += 1; + let id = history.next; + let mut affected: Vec = roots.into_iter().map(Into::into).collect(); + affected.extend(record.documents.iter().map(|(_, state)| state.path.clone().into())); + affected.sort(); + affected.dedup(); + history.records.push_back((id, record)); + if !history.reaper { + history.reaper = true; + let weak = Arc::downgrade(&self.workspace_edits); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(60)); + let Some(history) = weak.upgrade() else { break }; + let mut history = history.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + if history.records.is_empty() { + history.reaper = false; + break; + } + } + }); + } + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected }); + } + Ok(()) + } + + fn backup(&self, roots: &[PathBuf]) -> IdeResult { + let bytes = roots.iter().try_fold(0u64, |bytes, path| { + let size = if fs::symlink_metadata(path).is_ok() { snapshot_bytes(path)? } else { 0 }; + Ok::<_, IdeError>(bytes.saturating_add(size)) + })?; + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir()?; + // Publish the owner path only after acquiring its lock, so another + // starting process cannot mistake a new snapshot for an abandoned one. + let owner = tempfile::NamedTempFile::new_in(directory.path())?; + owner.as_file().lock()?; + let owner = owner + .persist_noclobber(directory.path().join("owner.lock")) + .map_err(|error| IdeError::from(error.error))?; + let mut paths = Vec::new(); + for (index, path) in roots.iter().enumerate() { + let backup = match fs::symlink_metadata(path) { + Ok(_) => { + let backup = directory.path().join(format!("before-{index}")); + copy_snapshot(path, &backup)?; + Some(backup) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + paths.push(SavedPath { path: path.clone(), backup }); + } + let mut documents = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + if roots.iter().any(|p| text_path(&state.path).is_ok_and(|file| file.starts_with(p))) { + documents.push((id, state.clone())); + } + Ok(()) + })?; + } + let manifest: Vec<_> = paths + .iter() + .map(|saved| { + ( + ide_core::IpcPath::from(saved.path.clone()), + saved.backup.as_ref().map(|path| ide_core::IpcPath::from(path.clone())), + ) + }) + .collect(); + fs::write( + directory.path().join("paths.json"), + serde_json::to_vec(&manifest).map_err(|error| IdeError::internal(error.to_string()))?, + )?; + let bytes = snapshot_bytes(directory.path())? + .saturating_add(documents.iter().map(|(_, state)| u64::from(state.doc.len_utf16()) * 2).sum::()); + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + Ok(Record { + _owner: owner, + directory, + created: SystemTime::now(), + bytes, + undone: false, + paths, + documents, + after: Vec::new(), + }) + } + + fn preflight(&self, record: &Record, edit: &WorkspaceEdit) -> IdeResult<()> { + let stage = dunce::canonicalize(record.directory.path())?.join("stage"); + let map = |path: &Path| -> PathBuf { + let volume = path.ancestors().last().expect("absolute path"); + let key = blake3::hash(volume.as_os_str().as_encoded_bytes()).to_hex(); + stage.join(key.as_str()).join(path.strip_prefix(volume).expect("inside volume")) + }; + for root in &record.paths { + if let Some(backup) = &root.backup { + let target = map(&root.path); + if fs::symlink_metadata(&target).is_err() { + copy_snapshot(backup, &target)?; + } + } + } + let staged = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + staged.set_file_defaults(self.file_defaults()); + let mut seed = record.documents.clone(); + // Closed files need the original path's encoding/EditorConfig too, not + // the settings of the temporary staging directory. + for file in &edit.files { + if let FileEdit::Edit { path, .. } = file + && path.is_file() + && self.by_path(path).is_none() + { + let info = self.open(path)?; + let state = self.with_doc(info.id, |state| Ok(state.clone())); + self.close(info.id); + seed.push((info.id, state?)); + } + } + for (id, state) in &seed { + let mut state = state.clone(); + state.path = map(&text_path(&state.path)?); + state.observed = false; + let keys = keys_for(&state.path); + let mut registry = staged.registry.lock(); + for key in &keys { + registry.by_key.insert(key.clone(), *id); + } + registry.docs.insert(*id, RegEntry { slot: Arc::new(Mutex::new(state)), refs: 1, keys }); + } + let files = edit + .files + .iter() + .map(|file| match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: map(path).into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: map(path).into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => FileEdit::Rename { from: map(from).into(), to: map(to).into() }, + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: map(path).into(), recursive: *recursive } + } + }) + .collect(); + staged.apply_sequence(&WorkspaceEdit { files, preconditions: Vec::new() }, &mut |_| Ok(())).map_err( + |mut error| { + for root in &record.paths { + error.message = error + .message + .replace(&map(&root.path).to_string_lossy().to_string(), &root.path.to_string_lossy()); + } + error + }, + ) + } + + fn apply_sequence( + &self, + edit: &WorkspaceEdit, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + for (index, file) in edit.files.iter().enumerate() { + before_operation(index)?; + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + check_link_parents(path)?; + } + FileEdit::Rename { from, to } => { + check_link_parents(from)?; + check_link_parents(to)?; + } + } + match file { + FileEdit::Edit { path, edits, .. } => { + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot edit a symbolic link introduced by a workspace edit")); + } + let info = self.open(path)?; + let result: IdeResult<()> = (|| { + // In-place rename restores its temporary typing before the + // server request. A dirty flag alone can therefore describe + // a buffer whose encoded bytes already match the saved file. + let unsaved = self.with_doc(info.id, |state| Ok(state.dirty && differs_from_disk(state)?))?; + self.apply_edits(info.id, edits)?; + // Do not save unrelated unsaved work as a side effect of a refactoring. + if !unsaved { + self.save(info.id)?; + } else { + self.with_doc(info.id, |state| { + ide_vfs::encode(&state.doc.text_for_save(), &state.encoding, state.has_bom).map(|_| ()) + })?; + } + Ok(()) + })(); + self.close(info.id); + result?; + } + FileEdit::Create { path, overwrite } => { + if path.exists() { + if !overwrite { + return Err(IdeError::new( + ErrorKind::AlreadyExists, + format!("{} already exists", path.display()), + )); + } + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot overwrite a symbolic link in a workspace edit")); + } + if !path.is_file() { + return Err(IdeError::invalid("cannot overwrite a directory")); + } + ide_vfs::save_text(path, "", "utf-8", false)?; + } else { + ide_vfs::create_file(path)?; + } + self.reload_resource_buffers(&[path.to_path_buf()])?; + } + FileEdit::Rename { from, to } => { + self.check_resource_buffers(from)?; + ide_vfs::rename(from, to)?; + } + FileEdit::Delete { path, recursive } => { + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.is_dir() && !recursive && fs::read_dir(path)?.next().is_some() { + return Err(IdeError::invalid("recursive deletion was not requested")); + } + ide_vfs::delete_permanently(path)?; + } + } + } + Ok(()) + } + + fn check_resource_buffers(&self, path: &Path) -> IdeResult<()> { + for id in self.open_documents() { + self.with_doc(id, |state| { + if state.dirty && text_path(&state.path)?.starts_with(path) { + return Err(IdeError::invalid(format!( + "save the unsaved changes in {} before changing its file", + state.path.display() + ))); + } + Ok(()) + })?; + } + Ok(()) + } + + fn restore(&self, record: &Record) -> IdeResult<()> { + let mut errors = Vec::new(); + let mut failed_paths = Vec::new(); + for saved in &record.paths { + let result = (|| { + match fs::symlink_metadata(&saved.path) { + Ok(_) => ide_vfs::delete_permanently(&saved.path)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + if let Some(backup) = &saved.backup { + copy_snapshot(backup, &saved.path)?; + } + Ok::<_, IdeError>(()) + })(); + if let Err(error) = result { + failed_paths.push(saved.path.clone()); + errors.push(format!("{}: {error}", saved.path.display())); + } + } + for (id, before) in &record.documents { + if self.slot(*id).is_err() { + continue; + } + let result = self.with_doc(*id, |state| { + self.replace_locked(*id, state, &before.doc.text())?; + state.encoding = before.encoding.clone(); + state.has_bom = before.has_bom; + state.doc.set_line_ending(before.doc.line_ending()); + state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0); + state.disk_len = disk_len(&state.path); + let disk_failed = + text_path(&state.path).is_ok_and(|path| failed_paths.iter().any(|failed| path.starts_with(failed))); + let dirty = disk_failed || (before.dirty && differs_from_disk(state)?); + self.set_dirty(*id, state, dirty); + Ok(()) + }); + if let Err(error) = result { + errors.push(error.to_string()); + } + } + // A file may have been opened after the refactoring; its clean buffer must follow undo too. + let paths: Vec = self + .open_documents() + .into_iter() + .filter(|id| !record.documents.iter().any(|(saved, _)| saved == id)) + .filter_map(|id| self.with_doc(id, |state| Ok(state.path.clone())).ok()) + .filter(|path| { + text_path(path).is_ok_and(|path| record.paths.iter().any(|saved| path.starts_with(&saved.path))) + }) + .collect(); + if let Err(error) = self.reload_resource_buffers(&paths) { + errors.push(error.to_string()); + } + if errors.is_empty() { Ok(()) } else { Err(IdeError::internal(errors.join("; "))) } + } + + fn reload_resource_buffers(&self, paths: &[PathBuf]) -> IdeResult<()> { + for path in paths { + let Some(id) = self.by_path(path) else { continue }; + if !path.is_file() { + continue; + } + self.with_doc(id, |state| { + if state.dirty { + return Err(stale(path)); + } + let defaults = self.file_defaults(); + let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?; + self.replace_locked(id, state, &loaded.text)?; + state.encoding = loaded.encoding; + state.has_bom = loaded.has_bom; + state.disk_mtime = loaded.mtime_ms; + state.disk_len = disk_len(path); + Ok(()) + })?; + } + Ok(()) + } + + fn recover(&self, record: Record, error: IdeError) -> IdeError { + match self.restore(&record) { + Ok(()) => error, + Err(rollback) => { + let directory = record.directory.keep(); + IdeError::new( + error.kind, + format!("{error}; rollback failed: {rollback}. Recovery files: {}", directory.display()), + ) + } + } + } + + /// Explicitly release disposable snapshots before an exit that bypasses Drop. + /// Failed-rollback recovery directories are detached and remain untouched. + pub fn clear_workspace_edit_history(&self) { + let _operation = self.operations.lock(); + self.workspace_edits.lock().records.clear(); + } + + /// Refuse to overwrite work made after the refactoring; keep the undo record on failure. + pub fn undo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, false) + } + + pub fn redo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, true) + } + + fn workspace_edit_history(&self, id: u32, redo: bool) -> IdeResult<()> { + let _operation = self.operations.lock(); + let mut history = self.workspace_edits.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let index = history + .records + .iter() + .position(|(key, _)| *key == id) + .ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?; + let record = &history.records[index].1; + if record.undone != redo { + return Err(IdeError::invalid("the refactoring is already in the requested history state")); + } + self.validate_preconditions(&record.after)?; + let roots = record.paths.iter().map(|p| p.path.clone()).collect::>(); + let mut current = self.backup(&roots)?; + fs::write(current.directory.path().join("recovery"), b"history restore in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.restore(record) { + return Err(self.recover(current, error)); + } + current.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(current, error)), + }; + current.undone = !redo; + current.created = record.created; + if let Err(error) = fs::remove_file(current.directory.path().join("recovery")) { + return Err(self.recover(current, error.into())); + } + history.records[index].1 = current; + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + Ok(()) + } +} + +fn check_link_parents(path: &Path) -> IdeResult<()> { + for parent in path.ancestors().skip(1) { + match fs::symlink_metadata(parent) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(IdeError::invalid(format!("workspace edit traverses symbolic link {}", parent.display()))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Snapshot links themselves, including dangling links, instead of their targets. +fn copy_snapshot(from: &Path, to: &Path) -> IdeResult<()> { + let metadata = fs::symlink_metadata(from)?; + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + if metadata.file_type().is_symlink() { + let target = fs::read_link(from)?; + #[cfg(unix)] + std::os::unix::fs::symlink(target, to)?; + #[cfg(windows)] + { + use std::os::windows::fs::FileTypeExt; + if metadata.file_type().is_symlink_dir() { + std::os::windows::fs::symlink_dir(target, to)?; + } else { + std::os::windows::fs::symlink_file(target, to)?; + } + } + } else if metadata.is_dir() { + fs::create_dir(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_snapshot(&entry.path(), &to.join(entry.file_name()))?; + } + fs::set_permissions(to, metadata.permissions())?; + } else if metadata.is_file() { + fs::copy(from, to)?; + } else { + return Err(IdeError::invalid("cannot snapshot a special file")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::{ChangeSet, Pos, Range, TextChange, TextEdit}; + + #[test] + fn snapshot_size_age_and_exit_cleanup_release_files() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("file.txt"); + fs::write(&path, "old").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let mut first = docs.backup(std::slice::from_ref(&path)).unwrap(); + let old_dir = first.directory.path().to_owned(); + let second = docs.backup(std::slice::from_ref(&path)).unwrap(); + let new_dir = second.directory.path().to_owned(); + let max_bytes = second.bytes; + first.created = SystemTime::now() - MAX_HISTORY_AGE; + let mut history = docs.workspace_edits.lock(); + history.records.push_back((1, first)); + history.records.push_back((2, second)); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + assert!(!old_dir.exists(), "expired snapshots are deleted, not just unindexed"); + assert!(new_dir.exists()); + let third = docs.backup(std::slice::from_ref(&path)).unwrap(); + let last_dir = third.directory.path().to_owned(); + history.records.push_back((3, third)); + history.prune(SystemTime::now(), max_bytes, MAX_HISTORY_AGE); + assert!(!new_dir.exists(), "the byte budget evicts the oldest record"); + assert!(last_dir.exists()); + drop(history); + docs.clear_workspace_edit_history(); + assert!(!last_dir.exists(), "normal exit explicitly releases snapshots"); + } + + #[test] + fn oversized_snapshot_is_refused_before_mutating_any_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("huge.bin"); + fs::File::create(&path).unwrap().set_len(MAX_HISTORY_BYTES + 1).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let error = docs + .apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: path.clone().into(), recursive: false }], + ..Default::default() + }) + .unwrap_err(); + assert!(error.message.contains("256 MiB")); + assert_eq!(fs::metadata(path).unwrap().len(), MAX_HISTORY_BYTES + 1); + } + + #[test] + fn snapshot_crash_child() { + let Some(root) = std::env::var_os("OPENIDEA_SNAPSHOT_CRASH_FIXTURE") else { return }; + let root = PathBuf::from(root); + for (name, recovery) in [("undo", false), ("recovery", true)] { + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(&root).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(directory.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + fs::write(directory.path().join("paths.json"), b"[]").unwrap(); + if recovery { + fs::write(directory.path().join("recovery"), b"rollback required").unwrap(); + } + fs::write(root.join(name), directory.path().to_string_lossy().as_bytes()).unwrap(); + // Simulate process death: neither TempDir nor the owner lock is dropped. + std::mem::forget(owner); + std::mem::forget(directory); + } + std::process::exit(0); + } + + #[test] + fn startup_cleanup_after_crash_keeps_live_and_recovery_snapshots() { + let root = tempfile::tempdir().unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "documents::workspace_edit::tests::snapshot_crash_child"]) + .env("OPENIDEA_SNAPSHOT_CRASH_FIXTURE", root.path()) + .status() + .unwrap(); + assert!(status.success()); + let undo = PathBuf::from(fs::read_to_string(root.path().join("undo")).unwrap()); + let recovery = PathBuf::from(fs::read_to_string(root.path().join("recovery")).unwrap()); + assert!(undo.exists()); + let live = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(root.path()).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(live.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + let unknown = root.path().join(format!("{SNAPSHOT_PREFIX}legacy-recovery")); + fs::create_dir(&unknown).unwrap(); + cleanup_abandoned(root.path()); + assert!(!undo.exists()); + assert!(recovery.exists(), "crash recovery files are never expired or size-evicted"); + assert!(live.path().exists(), "another live app's owner lock protects its snapshots"); + assert!(unknown.exists(), "legacy recovery files have no safe deletion marker"); + drop(owner); + cleanup_abandoned(root.path()); + assert!(!live.path().exists()); + } + + #[test] + fn java_mid_apply_failure_rolls_back_declaration_and_closed_usage() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("Main.java"); + let usage = tmp.path().join("Use.java"); + fs::write(&declaration, "class Original {}\n").unwrap(); + fs::write(&usage, "class Use { Original value; }\n").unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + let docs = + Documents::new(Arc::new(move |e| sink.lock().push(e)), Box::new(|_, _| ide_core::LanguageId::new("java"))); + let info = docs.open(&declaration).unwrap(); + let edit = WorkspaceEdit { + files: vec![(declaration.clone(), 6, Some(info.version as i32)), (usage.clone(), 12, None)] + .into_iter() + .map(|(path, col, version)| FileEdit::Edit { + path: path.into(), + version, + edits: vec![TextEdit { + range: Range::new(Pos::new(0, col), Pos::new(0, col + 8)), + new_text: "Renamed".into(), + }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::internal("injected Java usage write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected Java")); + assert_eq!(fs::read_to_string(&declaration).unwrap(), "class Original {}\n"); + assert_eq!(fs::read_to_string(&usage).unwrap(), "class Use { Original value; }\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "class Original {}\n"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + assert!( + events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::DocPatched { workspace_edit, .. } => Some(workspace_edit), + _ => None, + }) + .all(|grouped| *grouped) + ); + } + + #[test] + fn failed_second_write_rolls_back_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "old\r\n").unwrap(); + fs::write(&b, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&a).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let edit = WorkspaceEdit { + files: [&a, &b] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::new(ErrorKind::Io, "injected write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected")); + assert_eq!(fs::read(&a).unwrap(), b"old\r\n"); + assert_eq!(fs::read(&b).unwrap(), b"two\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); + assert!(after.version > info.version); + assert!(docs.workspace_edits.lock().records.is_empty()); + } + + #[test] + fn failed_commit_restores_encoded_files_and_resource_operations() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let created = tmp.path().join("new/deep/file.txt"); + let source = tmp.path().join("source.bin"); + let renamed = tmp.path().join("renamed.bin"); + let deleted = tmp.path().join("deleted.bin"); + ide_vfs::save_text(&first, "old\r\n", "utf-16le", true).unwrap(); + let bytes = fs::read(&first).unwrap(); + fs::write(&source, [0, 255, 128]).unwrap(); + fs::write(&deleted, [255, 1, 128]).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: vec![ + FileEdit::Edit { + path: first.clone().into(), + version: Some(info.version as i32), + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }, + FileEdit::Create { path: created.clone().into(), overwrite: false }, + FileEdit::Rename { from: source.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: tmp.path().join("last").into(), overwrite: false }, + ], + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 4 { Err(IdeError::new(ErrorKind::Io, "injected commit failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert_eq!(error.message, "injected commit failure"); + assert_eq!(fs::read(&first).unwrap(), bytes); + assert_eq!(fs::read(&source).unwrap(), [0, 255, 128]); + assert_eq!(fs::read(&deleted).unwrap(), [255, 1, 128]); + assert!(!renamed.exists()); + assert!(!tmp.path().join("new").exists()); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert_eq!(after.encoding, info.encoding); + assert_eq!(after.line_ending, info.line_ending); + assert!(!after.dirty); + } + + #[test] + fn failed_rollback_keeps_recovery_files_and_marks_the_restored_buffer_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let parent = tmp.path().join("subdir"); + fs::create_dir(&parent).unwrap(); + let first = parent.join("first.txt"); + let second = tmp.path().join("second.txt"); + fs::write(&first, "old\n").unwrap(); + fs::write(&second, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: [&first, &second] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index != 1 { + return Ok(()); + } + fs::remove_file(&first)?; + fs::remove_dir(&parent)?; + fs::write(&parent, "blocks restoration")?; + Err(IdeError::new(ErrorKind::Io, "injected commit failure")) + }) + .unwrap_err(); + assert!(error.message.contains("rollback failed")); + let recovery = PathBuf::from(error.message.split("Recovery files: ").last().unwrap()); + let manifest: Vec<(ide_core::IpcPath, Option)> = + serde_json::from_slice(&fs::read(recovery.join("paths.json")).unwrap()).unwrap(); + let backup = manifest + .iter() + .find(|(path, _)| path.file_name().is_some_and(|name| name == "first.txt")) + .unwrap() + .1 + .as_ref() + .unwrap(); + assert_eq!(fs::read(backup).unwrap(), b"old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert!(after.dirty, "a buffer whose disk restoration failed must not look saved"); + fs::remove_dir_all(recovery).unwrap(); + } +} diff --git a/crates/ide-workspace/src/file_moves.rs b/crates/ide-workspace/src/file_moves.rs index dbac62857..ad6444b89 100644 --- a/crates/ide-workspace/src/file_moves.rs +++ b/crates/ide-workspace/src/file_moves.rs @@ -89,7 +89,7 @@ fn write_text(documents: &Documents, path: &Path, text: &str) -> IdeResult<()> { fn edited_paths(edit: &WorkspaceEdit) -> Vec { let mut out: Vec = Vec::new(); for f in &edit.files { - if let FileEdit::Edit { path, edits } = f + if let FileEdit::Edit { path, edits, .. } = f && !edits.is_empty() && !out.iter().any(|p| p == path) { @@ -245,17 +245,7 @@ mod tests { } fn apply(documents: &Documents) -> impl Fn(&WorkspaceEdit) -> IdeResult<()> + '_ { - move |edit| { - for f in &edit.files { - if let FileEdit::Edit { path, edits } = f { - let doc = documents.open(path)?.id; - documents.apply_edits(doc, edits)?; - documents.save(doc)?; - documents.close(doc); - } - } - Ok(()) - } + move |edit| documents.apply_file_move_edits(edit) } #[test] @@ -272,8 +262,10 @@ mod tests { let edit = WorkspaceEdit { files: vec![FileEdit::Edit { path: root.join("main.ts").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 21), Pos::new(0, 22)), new_text: "b".into() }], }], + ..Default::default() }; let id = moves.perform(&docs, Some(&history), &mv, &edit, "Move", &apply(&docs)).unwrap(); assert!(root.join("b/util.ts").exists() && !root.join("a").exists()); @@ -303,13 +295,16 @@ mod tests { files: vec![ FileEdit::Edit { path: root.join("Foo.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 6), Pos::new(0, 9)), new_text: "Bar".into() }], }, FileEdit::Edit { path: root.join("Use.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "Bar".into() }], }, ], + ..Default::default() }; let mv = vec![FileMove { from: root.join("Foo.java").into(), to: root.join("Bar.java").into() }]; let id = moves.perform(&docs, None, &mv, &edit, "Rename", &apply(&docs)).unwrap(); diff --git a/crates/ide-workspace/src/text_refs.rs b/crates/ide-workspace/src/text_refs.rs index ab3447801..1ea4daa15 100644 --- a/crates/ide-workspace/src/text_refs.rs +++ b/crates/ide-workspace/src/text_refs.rs @@ -201,7 +201,11 @@ pub fn find_text_occurrences( let mut files = found.into_inner(); files.sort_by(|a, b| a.0.cmp(&b.0)); WorkspaceEdit { - files: files.into_iter().map(|(path, edits)| FileEdit::Edit { path: path.into(), edits }).collect(), + files: files + .into_iter() + .map(|(path, edits)| FileEdit::Edit { path: path.into(), edits, version: None }) + .collect(), + ..Default::default() } } diff --git a/crates/ide-workspace/tests/workspace_edits.rs b/crates/ide-workspace/tests/workspace_edits.rs new file mode 100644 index 000000000..78bd905bd --- /dev/null +++ b/crates/ide-workspace/tests/workspace_edits.rs @@ -0,0 +1,463 @@ +use ide_core::{ChangeSet, FileEdit, LanguageId, NullSink, Pos, Range, TextChange, TextEdit, WorkspaceEdit}; +use ide_core::{IdeEvent, error::ErrorKind}; +use ide_workspace::Documents; +use parking_lot::Mutex; +use std::path::Path; +use std::sync::Arc; + +fn documents() -> Documents { + Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text())) +} + +fn edit(path: &Path, text: &str) -> FileEdit { + FileEdit::Edit { + path: path.to_owned().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: text.into() }], + } +} + +#[test] +fn later_failure_preserves_disk_and_unsaved_buffer() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let missing = tmp.path().join("missing.txt"); + std::fs::write(&first, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&first).unwrap(); + docs.edit(info.id, info.version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), edit(&missing, "bad")], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); +} + +#[test] +fn create_conflict_does_not_modify_earlier_files() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let existing = tmp.path().join("exists.txt"); + std::fs::write(&first, "old\n").unwrap(); + std::fs::write(&existing, "keep").unwrap(); + let docs = documents(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), FileEdit::Create { path: existing.clone().into(), overwrite: false }], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(existing).unwrap(), "keep"); +} + +fn recorded_documents() -> (Documents, Arc>>) { + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + (Documents::new(Arc::new(move |event| sink.lock().push(event)), Box::new(|_, _| LanguageId::plain_text())), events) +} + +fn undo_id(events: &Mutex>) -> u32 { + events + .lock() + .iter() + .rev() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .expect("successful transaction has one undo record") +} + +#[test] +fn stale_document_version_rejects_the_whole_edit() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::write(&b, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&b).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "own ".into() }] }).unwrap(); + let mut second = edit(&b, "new"); + if let FileEdit::Edit { version, .. } = &mut second { + *version = Some(0); + } + let result = + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new"), second], ..Default::default() }); + assert_eq!(result.unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "own old\n"); + assert!(docs.is_dirty(info.id).unwrap()); +} + +#[test] +fn matching_document_version_is_applied() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&a).unwrap(); + let mut operation = edit(&a, "new"); + if let FileEdit::Edit { version, .. } = &mut operation { + *version = Some(info.version as i32); + } + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![operation], ..Default::default() }).unwrap(); + assert_eq!(docs.snapshot(info.id).unwrap().text, "new\n"); + assert_eq!(std::fs::read_to_string(a).unwrap(), "new\n"); +} + +#[test] +fn unversioned_preview_rejects_changed_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + for open in [false, true] { + let path = tmp.path().join(if open { "open.txt" } else { "closed.txt" }); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = open.then(|| docs.open(&path).unwrap()); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + if let Some(info) = &info { + docs.edit( + info.id, + info.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }, + ) + .unwrap(); + } else { + std::fs::write(&path, "own\n").unwrap(); + } + let original = change.preconditions.clone(); + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(change.preconditions, original, "resolution must not refresh an old preview's guard"); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + if let Some(info) = info { + assert_eq!(docs.snapshot(info.id).unwrap().text, "own\n"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\n"); + } else { + assert_eq!(std::fs::read_to_string(&path).unwrap(), "own\n"); + } + } +} + +#[test] +fn grouped_undo_restores_open_closed_and_unsaved_files() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + let c = tmp.path().join("c.txt"); + for path in [&a, &b, &c] { + std::fs::write(path, "old\r\n").unwrap(); + } + let (docs, events) = recorded_documents(); + let dirty = docs.open(&a).unwrap(); + let clean = docs.open(&b).unwrap(); + docs.edit(dirty.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }).unwrap(); + let change = + WorkspaceEdit { files: [&a, &b, &c].into_iter().map(|p| edit(p, "new")).collect(), ..Default::default() }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "new\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + assert_eq!(std::fs::read(&a).unwrap(), b"old\r\n", "unrelated unsaved work is not saved"); + assert_eq!(std::fs::read(&c).unwrap(), b"new\r\n"); + let opened_later = docs.open(&c).unwrap(); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "own\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + for info in [clean, opened_later] { + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); + assert!(!docs.is_dirty(info.id).unwrap()); + } + for path in [&a, &b, &c] { + assert_eq!(std::fs::read(path).unwrap(), b"old\r\n"); + } + assert!(docs.undo_workspace_edit(id).is_err()); +} + +#[test] +fn undo_refuses_later_changes_and_retains_the_record() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + std::fs::write(&a, "own\n").unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "own\n"); + std::fs::write(&a, "new\n").unwrap(); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); +} + +#[test] +fn ordered_resource_operations_and_binary_deletion_are_undoable() { + let tmp = tempfile::tempdir().unwrap(); + let created = tmp.path().join("new/deep/file.txt"); + let renamed = tmp.path().join("renamed.txt"); + let deleted = tmp.path().join("binary.dat"); + let overwritten = tmp.path().join("overwrite.txt"); + std::fs::write(&deleted, [0, 255, 1, 128]).unwrap(); + std::fs::write(&overwritten, [255, 254, 65, 0]).unwrap(); + let (docs, events) = recorded_documents(); + let change = WorkspaceEdit { + files: vec![ + FileEdit::Create { path: created.clone().into(), overwrite: false }, + edit(&created, "hello"), + FileEdit::Rename { from: created.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: overwritten.clone().into(), overwrite: true }, + ], + ..Default::default() + }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(std::fs::read_to_string(&renamed).unwrap(), "hello"); + assert!(!deleted.exists()); + assert!(std::fs::read(&overwritten).unwrap().is_empty()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert!(!tmp.path().join("new").exists()); + assert!(!renamed.exists()); + assert_eq!(std::fs::read(deleted).unwrap(), [0, 255, 1, 128]); + assert_eq!(std::fs::read(overwritten).unwrap(), [255, 254, 65, 0]); +} + +#[test] +fn nonrecursive_directory_delete_is_rejected_before_any_write() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let dir = tmp.path().join("directory"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("keep.txt"), "keep").unwrap(); + let change = WorkspaceEdit { + files: vec![edit(&a, "new"), FileEdit::Delete { path: dir.clone().into(), recursive: false }], + ..Default::default() + }; + assert!(documents().apply_workspace_edit(&change).is_err()); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); + assert!(dir.join("keep.txt").exists()); +} + +#[test] +fn external_write_not_yet_seen_by_watcher_is_not_overwritten() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("a.txt"); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + std::fs::write(&path, "own\n").unwrap(); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "own\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); +} + +#[cfg(unix)] +#[test] +fn undo_restores_deleted_directory_with_symlinks_and_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("tree"); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("script"), "old\n").unwrap(); + std::fs::set_permissions(dir.join("script"), std::fs::Permissions::from_mode(0o751)).unwrap(); + symlink("missing-target", dir.join("link")).unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: dir.clone().into(), recursive: true }], + ..Default::default() + }) + .unwrap(); + assert!(!dir.exists()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert_eq!(std::fs::read(dir.join("script")).unwrap(), b"old\n"); + assert_eq!(std::fs::metadata(dir.join("script")).unwrap().permissions().mode() & 0o777, 0o751); + assert_eq!(std::fs::read_link(dir.join("link")).unwrap(), Path::new("missing-target")); +} + +#[cfg(unix)] +#[test] +fn preflight_never_writes_through_copied_or_moved_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target.txt"); + let link = tmp.path().join("link.txt"); + let moved = tmp.path().join("moved.txt"); + std::fs::write(&target, "old\n").unwrap(); + symlink(&target, &link).unwrap(); + let docs = documents(); + for files in [ + vec![FileEdit::Create { path: link.clone().into(), overwrite: true }], + vec![FileEdit::Rename { from: link.clone().into(), to: moved.clone().into() }, edit(&moved, "new")], + ] { + assert!(docs.apply_workspace_edit(&WorkspaceEdit { files, ..Default::default() }).is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old\n"); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert!(!moved.exists()); + } +} + +#[test] +fn rename_after_restoring_inplace_typing_saves_but_real_unsaved_work_stays_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + for (version, text) in [(0, "own"), (1, "old")] { + docs.edit(info.id, version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty, "restoring saved bytes must clear a stale dirty flag"); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 3, to: 3, insert: " own".into() }] }, + ) + .unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); +} + +#[test] +fn duplicate_edits_keep_versions_and_apply_one_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.cpp"); + std::fs::write(&path, "old").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + let mut versioned = edit(&path, "new_long"); + if let FileEdit::Edit { version, .. } = &mut versioned { + *version = Some(info.version as i32); + } + let mut workspace = WorkspaceEdit { files: vec![edit(&path, "new_long"), versioned], ..Default::default() }; + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 1); + assert!(matches!(workspace.files[0], FileEdit::Edit { version: Some(0), .. })); + docs.apply_workspace_edit(&workspace).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new_long"); + + let mut conflicting = workspace.files[0].clone(); + if let FileEdit::Edit { version, .. } = &mut conflicting { + *version = Some(99); + } + workspace.files.push(conflicting); + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 2, "deduplication must not discard incompatible versions"); + assert_eq!(docs.apply_workspace_edit(&workspace).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long"); +} + +#[test] +fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + for text in ["own", "new"] { + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }, + ) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "old"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty); +} + +#[cfg(unix)] +#[test] +fn replacing_a_symlink_then_editing_the_new_file_never_writes_its_old_target() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious.txt"); + let link = tmp.path().join("new.txt"); + std::fs::write(&target, "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let (docs, events) = recorded_documents(); + let target_doc = docs.open(&target).unwrap(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.clone().into(), overwrite: false }, + FileEdit::Edit { + path: link.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(docs.snapshot(target_doc.id).unwrap().text, "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), target); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); +} + +#[cfg(unix)] +#[test] +fn replacing_a_directory_symlink_does_not_resolve_later_child_paths_through_its_old_target() { + for alias in 0..3 { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious"); + let link = tmp.path().join("new"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("file.txt"), "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let alternate = tmp.path().join("alternate"); + std::os::unix::fs::symlink(tmp.path(), &alternate).unwrap(); + let shortcut = tmp.path().join("shortcut"); + std::os::unix::fs::symlink(&link, &shortcut).unwrap(); + let edited = match alias { + 0 => link.join("file.txt"), + 1 => alternate.join("new/file.txt"), + _ => shortcut.join("file.txt"), + }; + let docs = documents(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.join("file.txt").into(), overwrite: false }, + FileEdit::Edit { + path: edited.into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(target.join("file.txt")).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(link.join("file.txt")).unwrap(), "new file\n"); + } +} diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 91e0781eb..5d366faad 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -20,22 +20,26 @@ pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) #[tauri::command] #[specta::specta] -pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { - ide_host::ops::doc::close(&state.host, window.label(), doc) +pub async fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::close(&host, &window, doc)).await } /// Applies a CodeMirror change set; returns the new version. Rejects with /// `outOfSync` when `base_version` is stale; the UI then calls `doc_snapshot`. #[tauri::command] #[specta::specta] -pub fn doc_edit( +pub async fn doc_edit( window: Window, state: State<'_, AppState>, doc: DocId, base_version: u32, changes: ChangeSet, ) -> IdeResult { - ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::edit(&host, &window, doc, base_version, changes)).await } #[tauri::command] @@ -92,13 +96,15 @@ pub async fn doc_set_encoding( #[tauri::command] #[specta::specta] -pub fn doc_set_line_ending( +pub async fn doc_set_line_ending( window: Window, state: State<'_, AppState>, doc: DocId, line_ending: LineEnding, ) -> IdeResult<()> { - ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::doc_set_line_ending(&host, &window, doc, line_ending)).await } /// Structure view: LSP document symbols when available (with tree-sitter diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 765d7e755..0092cdcf6 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -337,8 +337,30 @@ pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: Ser /// Applies a (previewed) workspace edit, e.g. after rename confirmation. #[tauri::command] #[specta::specta] -pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { - ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit) +pub async fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { + ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn prepare_workspace_edit( + window: Window, + state: State<'_, AppState>, + edit: WorkspaceEdit, +) -> IdeResult { + ide_host::ops::lsp::prepare_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn undo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::undo_workspace_edit(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn redo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::redo_workspace_edit(&state.host, window.label(), id).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1ef4d111b..4a8f3c06c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -228,6 +228,9 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_resend_diagnostics, lsp::lsp_restart, lsp::apply_workspace_edit, + lsp::prepare_workspace_edit, + lsp::undo_workspace_edit, + lsp::redo_workspace_edit, lsp::lsp_execute_command, lsp::lsp_resolve_command, lsp::lsp_execute_code_action, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e75910a3d..478b25f43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ pub fn run() { let _ = &early_opens; if let tauri::RunEvent::Exit = event { if let Some(state) = app.try_state::() { + state.host.clear_workspace_edit_history_on_exit(); state.host.shutdown_notebooks_on_exit(); } // Cleanup must finish before update/restart replaces this process. diff --git a/src/bindings.ts b/src/bindings.ts index e76c5885f..4f1913248 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -426,6 +426,9 @@ export const commands = { lspRestart: (server: ServerId) => typedError(__TAURI_INVOKE("lsp_restart", { server })), /** Applies a (previewed) workspace edit, e.g. after rename confirmation. */ applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("apply_workspace_edit", { edit })), + prepareWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("prepare_workspace_edit", { edit })), + undoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("undo_workspace_edit", { id })), + redoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("redo_workspace_edit", { id })), /** * Runs a server command (`workspace/executeCommand`), e.g. a completion item's * follow-up command. Edits the server requests arrive via `workspace/applyEdit`. @@ -3088,7 +3091,9 @@ export type FileCoverage = { branchesCovered: number, }; -export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[] } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; +export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[]; +/** LSP TextDocumentEdit version; null/unversioned edits use content preconditions. */ +version?: number | null } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; export type FileEntry = { name: string, @@ -3603,9 +3608,13 @@ export type IdeEvent = * The backend changed a document (reload from disk, formatting, refactoring). * The UI applies `changes` on top of `base_version`, yielding `version`. */ -{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[] } | +{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[]; +/** Part of a grouped transaction; the editor must not record a file-local undo. */ +workspaceEdit?: boolean } | /** A document's dirty flag changed (saved / modified externally). */ { type: "docDirty"; doc: DocId; dirty: boolean } | +/** A complete workspace edit can be undone as one operation. */ +{ type: "workspaceEditApplied"; id: number; paths: IpcPath[] } | /** * An open document's language changed (Settings › File Types, Override File * Type), or its UI mode (a framework's root marker appeared or went). @@ -6480,6 +6489,13 @@ export type Workspace = Workspace_Serialize | Workspace_Deserialize; /** Edits to many files, applied atomically from the user's point of view. */ export type WorkspaceEdit = { files: FileEdit[], + /** Content fingerprints captured before presenting an edit for confirmation. */ + preconditions?: WorkspaceEditPrecondition[], +}; + +export type WorkspaceEditPrecondition = { + path: IpcPath, + hash: string, }; export type WorkspaceSymbol = { diff --git a/src/editor/api.ts b/src/editor/api.ts index 9d1e81e01..671f0d9bc 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -619,3 +619,37 @@ export function codePreviewExtensions(path: string): Promise { export function _provideCodePreview(provider: ((path: string) => Promise) | null) { codePreviewProvider = provider; } + +let workspaceHistory: ((id: number, redo: boolean) => Promise) | null = null; + +/** Undo/redo a refactoring through the same history used by editor commands. */ +export function workspaceEditHistory(id: number, redo = false): Promise { + return workspaceHistory ? workspaceHistory(id, redo) : Promise.reject(new Error("Editor history is unavailable")); +} + +/** @internal editor/core only. */ +export function _provideWorkspaceHistory(run: (id: number, redo: boolean) => Promise): () => void { + workspaceHistory = run; + return () => { + if (workspaceHistory === run) workspaceHistory = null; + }; +} + +export interface WorkspaceEditHistoryState { + id: number; + undone: boolean; +} +const workspaceHistoryListeners = new Set<(state: WorkspaceEditHistoryState) => void>(); + +/** Successful workspace Undo/Redo, including commands invoked from an editor. */ +export function onWorkspaceEditHistory(listener: (state: WorkspaceEditHistoryState) => void): () => void { + workspaceHistoryListeners.add(listener); + return () => { + workspaceHistoryListeners.delete(listener); + }; +} + +/** @internal editor/core only. */ +export function _emitWorkspaceEditHistory(state: WorkspaceEditHistoryState) { + for (const listener of workspaceHistoryListeners) listener(state); +} diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 55dfc59af..8c1cfbef5 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -28,6 +28,7 @@ import { customRegions } from "./cm/regions"; export type LanguageInfo = LanguageInfo_Serialize; export interface DocPatchedEvent { + workspaceEdit?: boolean; doc: DocId; baseVersion: number; version: number; @@ -335,7 +336,7 @@ function lineIndex(text: string) { export interface MemoryBackend extends DocBackend { /** Test/dev hook: apply a backend-originated change and emit `docPatched`. */ - patch(doc: DocId, changes: TextChange[]): void; + patch(doc: DocId, changes: TextChange[], workspaceEdit?: boolean): void; /** Seed file contents before opening. */ files: Map; docs: Map; @@ -582,13 +583,13 @@ export function createMemoryBackend(files: Record = {}): MemoryB dirtyHandlers.add(h); return () => dirtyHandlers.delete(h); }, - patch(doc, changes) { + patch(doc, changes, workspaceEdit = false) { const d = get(doc); const before = d.text !== d.saved; const baseVersion = d.version; d.text = applyChanges(d.text, changes); d.version++; - const e = { doc, baseVersion, version: d.version, changes }; + const e = { doc, baseVersion, version: d.version, changes, workspaceEdit }; patched.forEach((h) => h(e)); setDirty(d, before); }, diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index daefba9e1..0ac7bd141 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -29,9 +29,19 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr const target = new AttachedView(); model.attach(target); let released = false; + const historyTarget = { + path, + state: () => target.view!.state, + dispatch: (tr: Transaction) => target.view?.dispatch(tr), + }; const plugin = ViewPlugin.define((view) => { target.view = view; + let detachHistory = () => {}; + let destroyed = false; + queueMicrotask(() => { + if (!destroyed) detachHistory = model.manager.workspaceHistory.attach(historyTarget); + }); const handle: TextEditorHandle = { view, ctx: model.ctx() }; _addEditor(handle); // The model may have moved on between `text()` and the view's creation. @@ -46,6 +56,8 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }); return { destroy() { + destroyed = true; + detachHistory(); _removeEditor(handle); if (target.view === view) target.view = null; }, @@ -57,6 +69,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr text: () => model.sync.localText.toString(), readOnly: model.getMeta().readOnly || model.virtual, extension: [ + model.manager.workspaceHistory.extension(historyTarget), plugin, documentSynced.of(() => model.sync.whenIdle()), EditorView.updateListener.of((u) => { diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index ff94d564a..9c70e8f61 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -62,6 +62,7 @@ import { import { setMethodLines, softWrapActive } from "../cm/prefs"; import type { EditorSlot } from "./slot"; import { tx } from "@/platform/i18n"; +import { WorkspaceHistory } from "./workspaceHistory"; export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null; @@ -261,7 +262,7 @@ export class DocModel { snapshot: () => manager.backend.snapshot(this.doc), }, { - applyRemote: (cs) => this.applyRemote(cs), + applyRemote: (cs, grouped) => this.applyRemote(cs, grouped), onDiscarded: () => manager.notify({ level: "warning", @@ -344,14 +345,14 @@ export class DocModel { this.scheduleRefresh(); } - private applyRemote(cs: ChangeSet) { + private applyRemote(cs: ChangeSet, grouped = false) { this.modified.map(cs); this.changeCounter++; const primary = this.primarySlot(); for (const s of this.slots) - s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(s === primary)]); + s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(!grouped && s === primary)]); // Without a tab, the first attached view keeps backend edits (Replace in Files…) undoable. - let first = !primary; + let first = !grouped && !primary; for (const a of this.attachments) { a.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(first)]); first = false; @@ -625,6 +626,12 @@ export interface ManagerHost { } export class DocumentManager { + readonly workspaceHistory = new WorkspaceHistory( + async () => { + await Promise.all([...this.byPath.values()].map((m) => m.sync.whenIdle())); + }, + (n) => this.notify(n), + ); readonly byPath = new Map(); readonly byId = new Map(); private opening = new Map>(); @@ -647,6 +654,7 @@ export class DocumentManager { start() { this.disposers.push( + this.workspaceHistory.start(), this.backend.onPatched((e) => this.byId.get(e.doc)?.sync.remote(e)), this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language, e.uiMode)) ?? (() => {}), this.backend.onDirty((e) => { diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index fdd5684f7..bb818b1cd 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -30,6 +30,7 @@ import { showContextMenu } from "@/ui"; import { baseExtensions, compartments, contributionExtensions } from "../cm/setup"; import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu"; import type { DocModel } from "./document"; +import type { WorkspaceHistoryTarget } from "./workspaceHistory"; import { isNavigation } from "./navHistory"; export interface SlotHost { @@ -52,6 +53,8 @@ export interface Reveal { export class EditorSlot { state: EditorState; + private historyTarget: WorkspaceHistoryTarget; + private detachHistory: () => void; view: EditorView | null = null; handle: TextEditorHandle | null = null; private scroll: StateEffect | null = null; @@ -71,6 +74,14 @@ export class EditorSlot { private host: SlotHost, init?: { from?: EditorSlot; reveal?: Reveal }, ) { + this.historyTarget = { + path: model.path, + state: () => this.currentState(), + dispatch: (tr) => { + if (this.view) this.view.dispatch(tr); + else this.state = tr.state; + }, + }; const source = init?.from; this.state = EditorState.create({ doc: model.sync.localText, @@ -91,6 +102,7 @@ export class EditorSlot { else if (source) this.scroll = source.scroll; if (init?.reveal) this.pendingReveal = init.reveal; model.addSlot(this); + this.detachHistory = model.manager.workspaceHistory.attach(this.historyTarget); } get ctx(): EditorDocContext { @@ -103,6 +115,7 @@ export class EditorSlot { private slotExtensions() { return [ + this.model.manager.workspaceHistory.extension(this.historyTarget), documentSynced.of(() => this.model.sync.whenIdle()), EditorView.updateListener.of((u) => { for (const l of this.listeners) l(u); @@ -170,6 +183,7 @@ export class EditorSlot { dispose() { if (this.disposed) return; + this.detachHistory(); this.unmount(); this.disposed = true; this.model.removeSlot(this); diff --git a/src/editor/core/model/workspaceHistory.test.ts b/src/editor/core/model/workspaceHistory.test.ts new file mode 100644 index 000000000..001d76f7f --- /dev/null +++ b/src/editor/core/model/workspaceHistory.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { redo, redoDepth, undo, undoDepth, history } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; +import { workspaceEditHistory } from "@/editor/api"; +import { installWorkspaceEditUndo } from "@/features/code-insight/workspaceUndo"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { createMemoryBackend, type MemoryBackend } from "../backend"; +import { _resetLanguages } from "../cm/languages"; +import { DocumentManager } from "./document"; +import { EditorSlot, type SlotHost } from "./slot"; +import { attachDocument } from "./attachment"; + +const commands = vi.hoisted(() => ({ undoWorkspaceEdit: vi.fn(), redoWorkspaceEdit: vi.fn() })); +vi.mock("@/bindings", () => ({ commands })); +const host: SlotHost = { onFocus() {}, onBlur() {}, onSelection() {}, onUserEdit() {}, onContextMenu() {} }; +let manager: DocumentManager; +let backend: MemoryBackend; +let notify = vi.fn<(n: { level: "info" | "warning" | "error"; title: string; body?: string }) => void>(); +let slots: EditorSlot[]; +let attached: EditorView | null; + +beforeEach(() => { + _resetLanguages(); + backend = createMemoryBackend({ "/p/A.java": "old", "/p/B.java": "old", "/p/closed.java": "old" }); + notify = vi.fn(); + manager = new DocumentManager(backend, { settings: () => ({ files: { autoSave: false } }), notify }); + manager.start(); + slots = []; + attached = null; + commands.undoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("new"); + return { status: "ok", data: null }; + }); +}); +afterEach(() => { + attached?.destroy(); + slots.forEach((s) => s.dispose()); + manager.stop(); +}); + +async function open(path: string, from?: EditorSlot) { + const slot = new EditorSlot(await manager.acquire(path), String(slots.length), host, from ? { from } : undefined); + slots.push(slot); + return slot; +} +async function replace(text: string) { + for (const d of backend.docs.values()) { + backend.patch(d.id, [{ from: 0, to: 3, insert: text }], true); + await backend.save(d.id); + } + backend.files.set("/p/closed.java", text); +} +async function rename() { + await replace("new"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] }); +} +function historyStep(slot: EditorSlot, forward = false) { + return (forward ? redo : undo)({ + state: slot.currentState(), + dispatch: (tr) => { + slot.state = tr.state; + }, + }); +} + +it("editor Undo and Redo restore the entire Java rename, with one step in every split and background tab", async () => { + const a = await open("/p/A.java"); + const split = await open("/p/A.java"); + const b = await open("/p/B.java"); + await rename(); + for (const s of slots) expect(undoDepth(s.currentState())).toBe(1); + expect(historyStep(split)).toBe(true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("old")); + for (const s of slots) { + expect(s.currentState().doc.toString()).toBe("old"); + expect(undoDepth(s.currentState())).toBe(0); + expect(redoDepth(s.currentState())).toBe(1); + } + expect(commands.undoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); + historyStep(b, true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("new")); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); +}); + +it("notification Undo consumes the same marker and never makes Cmd+Z reapply a single file", async () => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old"); + expect(historyStep(a)).toBe(false); + expect(redoDepth(a.currentState())).toBe(1); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); +}); + +it("preserves earlier local history and refuses the notification until later typing is undone", async () => { + const a = await open("/p/A.java"); + a.dispatchLocal({ changes: { from: 3, insert: "!" }, userEvent: "input.type" }); + await a.model.sync.whenIdle(); + await rename(); + a.dispatchLocal({ changes: { from: 4, insert: "?" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9)).rejects.toThrow("Undo newer editor changes"); + expect(commands.undoWorkspaceEdit).not.toHaveBeenCalled(); + undo({ state: a.currentState(), dispatch: (tr) => a.dispatchLocal(tr) }); + await a.model.sync.whenIdle(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old!"); + expect(undoDepth(a.currentState())).toBe(1); +}); + +it("restores the editor marker when backend guards refuse Undo", async () => { + const a = await open("/p/A.java"); + await rename(); + commands.undoWorkspaceEdit.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "external write" }, + }); + historyStep(a); + await vi.waitFor(() => expect(notify).toHaveBeenCalled()); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(redoDepth(a.currentState())).toBe(0); +}); + +it("supports direct CodeMirror Undo from an attached diff view", async () => { + const att = await attachDocument(manager, "/p/A.java"); + attached = new EditorView({ doc: att.text(), extensions: [att.extension, history()] }); + await Promise.resolve(); + await rename(); + undo(attached); + await vi.waitFor(() => expect(attached!.state.doc.toString()).toBe("old")); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); + expect(undoDepth(attached.state)).toBe(0); + attached.destroy(); + attached = null; + att.release(); +}); + +it.each(["closed file", "new split"])("can redo after a %s opens following Undo", async (kind) => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const next = await open(kind === "closed file" ? "/p/B.java" : "/p/A.java", kind === "new split" ? a : undefined); + expect(next.currentState().doc.toString()).toBe("old"); + historyStep(a, true); + await vi.waitFor(() => expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9)); + await vi.waitFor(() => expect(next.currentState().doc.toString()).toBe("new")); + expect(undoDepth(next.currentState())).toBe(1); + expect(notify).not.toHaveBeenCalled(); +}); + +it("keeps the refactoring notification in sync with editor Undo and Redo", async () => { + const notices: Parameters[0][] = []; + provideNotificationService({ + notify: (n) => { + notices.push(n); + }, + }); + const stop = installWorkspaceEditUndo(); + try { + const a = await open("/p/A.java"); + await rename(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + historyStep(a); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo")); + historyStep(a, true); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo")); + await notices.at(-1)!.actions![0]!.run(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo"); + await notices.at(-1)!.actions![0]!.run(); + expect(a.currentState().doc.toString()).toBe("new"); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + } finally { + stop(); + } +}); + +it("rebuilds multiple redo markers in order when a closed file opens", async () => { + const a = await open("/p/A.java"); + await rename(); + await replace("alt"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] }); + commands.undoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "new" : "old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "alt" : "new"); + return { status: "ok", data: null }; + }); + await workspaceEditHistory(10); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + expect(redoDepth(b.currentState())).toBe(2); + historyStep(b, true); + await vi.waitFor(() => expect(a.currentState().doc.toString()).toBe("new")); + historyStep(a, true); + await vi.waitFor(() => expect(b.currentState().doc.toString()).toBe("alt")); + expect(commands.redoWorkspaceEdit.mock.calls.map(([id]) => id)).toEqual([9, 10]); +}); + +it("refuses redo when a newly opened affected editor has newer typing", async () => { + await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + b.dispatchLocal({ changes: { from: 3, insert: " own" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9, true)).rejects.toThrow("Undo newer editor changes"); + expect(commands.redoWorkspaceEdit).not.toHaveBeenCalled(); + expect(b.currentState().doc.toString()).toBe("old own"); +}); diff --git a/src/editor/core/model/workspaceHistory.ts b/src/editor/core/model/workspaceHistory.ts new file mode 100644 index 000000000..3a05ea440 --- /dev/null +++ b/src/editor/core/model/workspaceHistory.ts @@ -0,0 +1,176 @@ +/** Workspace edits contribute history markers; their text patches never enter local history. */ +import { invertedEffects, isolateHistory, redo, undo } from "@codemirror/commands"; +import { EditorState, Prec, StateEffect, type Transaction, type Extension } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import { _emitWorkspaceEditHistory, _provideWorkspaceHistory, isExternalChange } from "@/editor/api"; +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; + +interface Marker { + id: number; + redo: boolean; +} +const marker = StateEffect.define(); + +export interface WorkspaceHistoryTarget { + path: string; + state(): EditorState; + dispatch(tr: Transaction): void; +} + +export class WorkspaceHistory { + private targets = new Set(); + private records = new Map(); + private project = useProject.getState().project; + private busy = false; + private moving = false; + + constructor( + private synced: () => Promise, + private notify: (n: { level: "error"; title: string; body: string }) => void, + ) {} + + start(): () => void { + const stop = onIdeEvent("workspaceEditApplied", ({ id, paths }) => { + if (this.project !== useProject.getState().project) { + this.records.clear(); + this.project = useProject.getState().project; + } + // A new edit invalidates redo for any overlapping workspace transaction. + for (const [key, record] of this.records) + if ( + record.undone && + record.paths.some((path) => this.affects(paths, path) || paths.some((root) => root.startsWith(`${path}/`))) + ) + this.records.delete(key); + this.records.set(id, { paths, undone: false }); + while (this.records.size > 20) this.records.delete(this.records.keys().next().value!); + for (const target of this.targets) if (this.affects(paths, target.path)) this.mark(target, id); + }); + const unprovide = _provideWorkspaceHistory((id, redo) => this.run(id, redo)); + return () => { + stop(); + unprovide(); + this.records.clear(); + }; + } + + private affects(paths: string[], path: string) { + return paths.some((root) => path === root || path.startsWith(`${root}/`)); + } + + attach(target: WorkspaceHistoryTarget): () => void { + this.targets.add(target); + const records = [...this.records].filter(([, record]) => this.affects(record.paths, target.path)); + for (const [id] of records) this.mark(target, id); + // Build the same done/undone marker stacks as existing editors, without + // replaying any backend operation or changing the newly opened text. + for (const [id, record] of records.reverse()) { + if (!record.undone) continue; + const tr = this.step(target, id, false); + if (tr) target.dispatch(tr); + } + return () => this.targets.delete(target); + } + + extension(target: WorkspaceHistoryTarget): Extension { + return [ + Prec.highest( + keymap.of([ + { key: "Mod-z", run: () => this.busy }, + { key: "Mod-Shift-z", run: () => this.busy }, + { key: "Mod-y", run: () => this.busy }, + ]), + ), + Prec.highest( + EditorView.domEventHandlers({ + beforeinput: (event) => { + if (this.busy && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) { + event.preventDefault(); + return true; + } + return false; + }, + }), + ), + invertedEffects.of((tr) => + tr.effects.filter((e) => e.is(marker)).map((e) => marker.of({ id: e.value.id, redo: !e.value.redo })), + ), + // History commands disable transaction filters. An extender observes the + // effect-only history step, including native beforeinput and direct CM undo. + EditorState.transactionExtender.of((tr) => { + if (!this.moving && (tr.isUserEvent("undo") || tr.isUserEvent("redo"))) { + const entry = tr.effects.find((e) => e.is(marker)); + if (entry) + void this.run(entry.value.id, entry.value.redo, target).catch((error: unknown) => { + this.notify({ + level: "error", + title: "Cannot undo refactoring", + body: String(error instanceof Error ? error.message : error), + }); + }); + } + return null; + }), + EditorState.changeFilter.of((tr) => !this.busy || isExternalChange(tr)), + ]; + } + + private mark(target: WorkspaceHistoryTarget, id: number) { + target.dispatch( + target.state().update({ effects: marker.of({ id, redo: true }), annotations: isolateHistory.of("full") }), + ); + } + + /** Inspect the next history step without dispatching it or starting another request. */ + private step(target: WorkspaceHistoryTarget, id: number, forward: boolean): Transaction | null { + let result: Transaction | null = null; + this.moving = true; + try { + (forward ? redo : undo)({ + state: target.state(), + dispatch: (tr) => { + if (tr.effects.some((e) => e.is(marker) && e.value.id === id && e.value.redo === forward)) result = tr; + }, + }); + } finally { + this.moving = false; + } + return result; + } + + async run(id: number, forward = false, source?: WorkspaceHistoryTarget): Promise { + if (this.busy) throw new Error("A refactoring history operation is in progress"); + this.busy = true; + try { + // Yield before reading a source: its marker transaction is still being built. + await this.synced(); + if (this.project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + const record = this.records.get(id); + if (!record || record.undone !== forward) throw new Error("The refactoring can no longer be undone"); + const targets = [...this.targets].filter((t) => t !== source && this.affects(record.paths, t.path)); + for (const target of targets) + if (!this.step(target, id, forward)) + throw new Error("Undo newer editor changes before undoing this refactoring"); + await call(forward ? commands.redoWorkspaceEdit(id) : commands.undoWorkspaceEdit(id)); + await this.synced(); + for (const target of targets) { + if (!this.targets.has(target)) continue; + const tr = this.step(target, id, forward); + if (tr) target.dispatch(tr); + } + record.undone = !forward; + _emitWorkspaceEditHistory({ id, undone: record.undone }); + } catch (error) { + // A refused backend restore must leave the initiating editor's history intact. + if (source && this.targets.has(source)) { + const tr = this.step(source, id, !forward); + if (tr) source.dispatch(tr); + } + throw error; + } finally { + this.busy = false; + } + } +} diff --git a/src/editor/core/sync/DocSync.ts b/src/editor/core/sync/DocSync.ts index d262f7adc..b0bb67c35 100644 --- a/src/editor/core/sync/DocSync.ts +++ b/src/editor/core/sync/DocSync.ts @@ -35,7 +35,7 @@ export interface SyncTransport { export interface SyncHost { /** Apply a change set (in local-text coordinates) to every view, annotated as remote. */ - applyRemote(changes: ChangeSet): void; + applyRemote(changes: ChangeSet, workspaceEdit?: boolean): void; /** Called after a hard resync discarded local changes the backend never saw. */ onDiscarded?(reason: unknown): void; /** Non-recoverable transport errors (logged by default). */ @@ -73,6 +73,7 @@ export function toTextChanges(cs: ChangeSet): TextChange[] { } interface RemotePatch { + workspaceEdit?: boolean; baseVersion: number; version: number; changes: TextChange[]; @@ -298,7 +299,7 @@ export class DocSync { } if (p.empty) return; this.localText = p.apply(this.localText); - this.host.applyRemote(p); + this.host.applyRemote(p, patch.workspaceEdit); } /** Last resort: adopt the backend text, dropping unacknowledged local changes. */ diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index aaeed7547..7a5ec9baa 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -23,7 +23,7 @@ import { type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; -import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { whenAllSynced, whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { comparePos, offsetToPos, rangeToOffsets } from "./positions"; @@ -44,6 +44,7 @@ import { tx } from "@/platform/i18n"; async function applyEdit(view: EditorView, edit: WorkspaceEdit) { try { + await whenAllSynced(); await lspRequest(() => commands.applyWorkspaceEdit(edit)); } catch (e) { reportLspError(view, e, true); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 366cbb6da..5c31bd84c 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -417,6 +417,29 @@ describe("rename wiring", () => { expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("only local variables"); }); + it.each(["typescript", "java"])("previews a %s multi-file rename and retains version guards", async (language) => { + cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 3))); + const edit = { + files: ["/p/Main.java", "/p/Use.java"].map((path) => ({ + kind: "edit", + path, + version: 7, + edits: [{ range: r(0, 0, 3), new_text: "new" }], + })), + preconditions: [{ path: "/p/Main.java", hash: "original" }], + }; + cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: null })); + const view = editor("old", language, 0); + await renameNamePart(view, { from: 0, to: 3 }, "new"); + const preview = useLspPopup.getState().popup; + expect(preview?.kind).toBe("preview"); + expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled(); + if (preview?.kind !== "preview") throw new Error("missing rename preview"); + expect(preview.summary.files).toBe(2); + preview.onConfirm(); + await vi.waitFor(() => expect(cmd.applyWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(edit)); + }); + describe("Typo: Rename to (issue #42)", () => { it("renames the whole name through the server, references included", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12))); diff --git a/src/features/analysis/AnalysisDialogs.tsx b/src/features/analysis/AnalysisDialogs.tsx index 55cffd851..cd9a8ce38 100644 --- a/src/features/analysis/AnalysisDialogs.tsx +++ b/src/features/analysis/AnalysisDialogs.tsx @@ -7,6 +7,7 @@ import { type DuplicateOptions, type StructuralFile, type StructuralLanguage, + type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { activeProfileId, listProfiles, validateInspections, type ProfileId } from "@/platform/inspections"; @@ -29,6 +30,7 @@ import { } from "./structural"; import styles from "./Analysis.module.css"; import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; function InspectCodeDialog({ onClose }: { onClose: () => void }) { const [scope, setScope] = useState(() => defaultScope()); @@ -254,7 +256,7 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () } else if (!result.files.length) { await showStructuralResults(form.pattern, result.files, result.summary); } else { - showReplacePreview(result.files); + await showReplacePreview(result.files); } }; @@ -322,7 +324,15 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () ); } -function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onClose: () => void }) { +function ReplacePreviewDialog({ + files, + preconditions, + onClose, +}: { + files: StructuralFile[]; + preconditions: WorkspaceEdit["preconditions"]; + onClose: () => void; +}) { const [excluded, setExcluded] = useState>(() => new Set()); const edit = useMemo(() => buildReplaceEdit(files, excluded), [files, excluded]); const count = edit.files.reduce((n, f) => n + (f.kind === "edit" ? f.edits.length : 0), 0); @@ -351,7 +361,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC disabled: count === 0, onClick: () => { onClose(); - void applyStructuralReplace(edit); + void applyStructuralReplace({ ...edit, preconditions }); }, }, { label: "Cancel", role: "cancel" }, @@ -389,5 +399,18 @@ export const showDuplicatesDialog = () => openDialog((close) => openDialog((close) => ); export const showStructuralDialog = (replace: boolean) => openDialog((close) => ); -export const showReplacePreview = (files: StructuralFile[]) => - openDialog((close) => ); +export const showReplacePreview = async (files: StructuralFile[]) => { + try { + const edit = buildReplaceEdit(files, new Set()); + const prepared = isTauri() ? await call(commands.prepareWorkspaceEdit(edit)) : edit; + openDialog((close) => ( + + )); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot preview replacements", + body: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/src/features/analysis/replacePreview.test.tsx b/src/features/analysis/replacePreview.test.tsx new file mode 100644 index 000000000..e1af8f8cf --- /dev/null +++ b/src/features/analysis/replacePreview.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { StructuralFile, WorkspaceEdit } from "@/platform/ipc"; +import { provideNotificationService } from "@/workbench/contributions"; +import { AnalysisDialogHost } from "./dialogs"; +import { showReplacePreview } from "./AnalysisDialogs"; + +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), apply: vi.fn(), synced: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, prepareWorkspaceEdit: mocks.prepare, applyWorkspaceEdit: mocks.apply }, + }; +}); +vi.mock("@/editor/api", async (original) => ({ + ...(await original()), + whenAllSynced: mocks.synced, +})); +const files: StructuralFile[] = ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + matches: [ + { + range: { start: { line: 0, col: 0 }, end: { line: 0, col: 3 } }, + text: "old", + lineText: "old", + replacement: "new", + }, + ], +})); +const preconditions = files.map((f) => ({ path: f.path, hash: `preview:${f.path}` })); +beforeEach(() => { + mocks.prepare + .mockReset() + .mockImplementation(async (edit: WorkspaceEdit) => ({ status: "ok", data: { ...edit, preconditions } })); + mocks.apply.mockReset().mockResolvedValue({ status: "ok", data: null }); + mocks.synced.mockReset().mockResolvedValue(undefined); + mocks.notify.mockReset(); + provideNotificationService({ notify: mocks.notify }); +}); +afterEach(() => { + if (screen.queryByRole("button", { name: "Cancel" })) fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + cleanup(); +}); + +it("captures guards before preview and preserves them when the user excludes a file", async () => { + render(); + await act(() => showReplacePreview(files)); + expect(mocks.prepare).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("checkbox", { name: "Replace in b.ts:1" })); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledOnce()); + expect(mocks.apply.mock.calls[0]![0]).toEqual({ + files: [{ kind: "edit", path: "/p/a.ts", edits: [{ range: files[0]!.matches[0]!.range, new_text: "new" }] }], + preconditions, + }); + expect(mocks.synced).toHaveBeenCalledOnce(); +}); + +it("reports a stale preview without refreshing guards or retrying the edits", async () => { + mocks.apply.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "a.ts changed; request again" }, + }); + render(); + await act(() => showReplacePreview(files)); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "error", title: "Replace Structurally", body: "a.ts changed; request again" }), + ), + ); + expect(mocks.prepare).toHaveBeenCalledOnce(); + expect(mocks.apply).toHaveBeenCalledOnce(); +}); diff --git a/src/features/analysis/structural.ts b/src/features/analysis/structural.ts index 5d35e0ea3..54ba88251 100644 --- a/src/features/analysis/structural.ts +++ b/src/features/analysis/structural.ts @@ -4,7 +4,7 @@ * one workspace edit. */ import { Channel } from "@tauri-apps/api/core"; -import { activeEditor } from "@/editor/api"; +import { activeEditor, whenAllSynced } from "@/editor/api"; import { executeAction } from "@/platform/actions"; import { call, @@ -160,6 +160,7 @@ export async function showStructuralResults( export async function applyStructuralReplace(edit: WorkspaceEdit): Promise { try { + await whenAllSynced(); await call(commands.applyWorkspaceEdit(edit)); } catch (e) { notifications().notify({ diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index 1ad1f06e4..410c12ee6 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -22,6 +22,7 @@ import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; @@ -60,6 +61,7 @@ export function activate(): () => void { installProblemsEvents(), installProblemDecorations(), installServerEvents(), + installWorkspaceEditUndo(), registerToolWindow({ id: "problems", title: "Problems", diff --git a/src/features/code-insight/workspaceUndo.test.ts b/src/features/code-insight/workspaceUndo.test.ts new file mode 100644 index 000000000..a972b9ed7 --- /dev/null +++ b/src/features/code-insight/workspaceUndo.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; + +const mocks = vi.hoisted(() => ({ undo: vi.fn(), synced: vi.fn(), history: vi.fn(() => () => {}) })); +vi.mock("@/editor/api", () => ({ + whenAllSynced: mocks.synced, + workspaceEditHistory: mocks.undo, + onWorkspaceEditHistory: mocks.history, +})); + +let stop: () => void; +let notices: Parameters[0][]; + +beforeEach(() => { + mocks.undo.mockReset().mockResolvedValue(undefined); + mocks.synced.mockReset().mockResolvedValue(undefined); + notices = []; + provideNotificationService({ + notify: (notice) => { + notices.push(notice); + }, + }); + stop = installWorkspaceEditUndo(); +}); + +afterEach(() => stop()); + +it("waits for pending typing before undoing the entire backend transaction", async () => { + let release: () => void = () => {}; + mocks.synced.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [] }); + const action = notices[0]!.actions![0]!; + expect(action.label).toBe("Undo"); + const pending = action.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + release(); + await pending; + expect(mocks.undo).toHaveBeenCalledExactlyOnceWith(17); +}); + +it("reports a refused undo instead of discarding newer work", async () => { + mocks.undo.mockRejectedValue(new Error("a.txt changed")); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [] }); + await notices[0]!.actions![0]!.run(); + expect(notices[1]).toMatchObject({ level: "error", title: "Cannot undo refactoring", body: "a.txt changed" }); +}); + +it("does not apply an old notification's id to a different project", async () => { + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [] }); + const previous = useProject.getState().project; + useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable }); + try { + await notices[0]!.actions![0]!.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + expect(notices[1]).toMatchObject({ level: "error", body: "The project for this refactoring is no longer open" }); + } finally { + useProject.setState({ project: previous }); + } +}); diff --git a/src/features/code-insight/workspaceUndo.ts b/src/features/code-insight/workspaceUndo.ts new file mode 100644 index 000000000..a95643708 --- /dev/null +++ b/src/features/code-insight/workspaceUndo.ts @@ -0,0 +1,55 @@ +import { onWorkspaceEditHistory, whenAllSynced, workspaceEditHistory } from "@/editor/api"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; + +/** The notification follows the same history state as editor Undo/Redo. */ +export function installWorkspaceEditUndo(): () => void { + let current: { id: number; project: ReturnType["project"]; undone: boolean } | null = + null; + const show = () => { + if (!current) return; + const entry = current; + const { id, project, undone } = entry; + notifications().notify({ + level: "info", + title: undone ? "Refactoring undone" : "Refactoring applied", + key: "refactor.workspaceEdit", + actions: [ + { + label: undone ? "Redo" : "Undo", + run: async () => { + try { + await whenAllSynced(); + if (project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + if (current !== entry || current.undone !== undone) return; + if (undone) await workspaceEditHistory(id, true); + else await workspaceEditHistory(id); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot undo refactoring", + body: error instanceof Error ? error.message : String(error), + }); + } + }, + }, + ], + }); + }; + const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => { + current = { id, project: useProject.getState().project, undone: false }; + show(); + }); + const stopHistory = onWorkspaceEditHistory(({ id, undone }) => { + if (!current || current.id !== id || current.project !== useProject.getState().project) return; + current = { ...current, undone }; + show(); + }); + return () => { + stopApplied(); + stopHistory(); + current = null; + }; +} diff --git a/src/features/project-tree/RefactoringPreview.tsx b/src/features/project-tree/RefactoringPreview.tsx index 00886ca96..fbcf8020d 100644 --- a/src/features/project-tree/RefactoringPreview.tsx +++ b/src/features/project-tree/RefactoringPreview.tsx @@ -138,7 +138,7 @@ function PreviewDialog({ request }: { request: PreviewRequest }) { label: "Do Refactor", role: "default", autoFocus: true, - onClick: () => request.resolve({ files: kept }), + onClick: () => request.resolve({ ...request.edit, files: kept }), }, { label: "Cancel", role: "cancel" }, ]} diff --git a/src/features/project-tree/fileRefactor.test.tsx b/src/features/project-tree/fileRefactor.test.tsx index 34eb34f94..1e6baf07c 100644 --- a/src/features/project-tree/fileRefactor.test.tsx +++ b/src/features/project-tree/fileRefactor.test.tsx @@ -22,6 +22,16 @@ const edit = (path: string, ...ranges: [number, number, number, string][]): Work }); describe("file refactoring edits", () => { + it("retains version guards and both sources' preview fingerprints", () => { + const lsp: WorkspaceEdit = { + files: [{ kind: "edit", path: "/p/main.ts", edits: [{ range: r(0, 0, 3), new_text: "new" }], version: 7 }], + preconditions: [{ path: "/p/main.ts", hash: "source" }], + }; + const text: WorkspaceEdit = { files: [], preconditions: [{ path: "/p/README.md", hash: "text" }] }; + const merged = mergeEdits(lsp, text); + expect(merged.files[0]).toMatchObject({ version: 7 }); + expect(merged.preconditions).toEqual([...lsp.preconditions!, ...text.preconditions!]); + }); it("merges text occurrences that no server edit covers and drops file operations", () => { const lsp: WorkspaceEdit = { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"]), { kind: "rename", from: "/p/a", to: "/p/b" }], @@ -58,7 +68,10 @@ describe("runFileRefactoring", () => { backend = { calls: [], edits: vi.fn(async () => ({ - edit: { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])] }, + edit: { + files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])], + preconditions: [{ path: "/p/main.ts", hash: "preview" }], + }, errors: ["tsserver: busy"], })), move: vi.fn(async (moves: FileMove[], e: WorkspaceEdit) => { @@ -86,6 +99,11 @@ describe("runFileRefactoring", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Do Refactor" })); expect(await status).toBe("done"); expect(backend.calls).toEqual(["move 1 1"]); + expect(backend.move).toHaveBeenCalledWith( + moves, + expect.objectContaining({ preconditions: [{ path: "/p/main.ts", hash: "preview" }] }), + "Move", + ); expect(lastFileRefactoring()?.id).toBe(7); // Server failures are reported; the result offers Undo. expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "warning", body: "tsserver: busy" })); diff --git a/src/features/project-tree/fileRefactor.ts b/src/features/project-tree/fileRefactor.ts index 63e3a65e1..6676af2d0 100644 --- a/src/features/project-tree/fileRefactor.ts +++ b/src/features/project-tree/fileRefactor.ts @@ -129,9 +129,15 @@ function overlaps(a: { start: { line: number; col: number }; end: { line: number * Only text edits are kept: the move itself is the refactoring's file operation. */ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEdit { + const versions = new Map(); + const guards: WorkspaceEdit["files"] = []; const byPath = new Map["edits"]>(); for (const f of lsp.files) { if (f.kind !== "edit") continue; + const previous = versions.get(f.path); + if (previous != null && f.version != null && previous !== f.version) + guards.push({ ...f, edits: [], version: previous }); + versions.set(f.path, f.version ?? previous); byPath.set(f.path, [...(byPath.get(f.path) ?? []), ...f.edits]); } for (const f of text.files) { @@ -140,7 +146,15 @@ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEd const extra = f.edits.filter((t) => !existing.some((e) => overlaps(e.range, t.range))); if (extra.length) byPath.set(f.path, [...existing, ...extra]); } - return { files: [...byPath].filter(([, e]) => e.length).map(([path, edits]) => ({ kind: "edit", path, edits })) }; + return { + files: [ + ...guards, + ...[...byPath] + .filter(([, e]) => e.length) + .map(([path, edits]) => ({ kind: "edit" as const, path, edits, version: versions.get(path) })), + ], + preconditions: [...(lsp.preconditions ?? []), ...(text.preconditions ?? [])], + }; } /** Whether `edit` changes files other than the moved ones (IDEA previews those refactorings). */ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f12eafbdc..4240050a1 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -326,6 +326,8 @@ "Cannot send the request": "无法发送请求", "Cannot stop process": "无法停止进程", "Cannot undo": "无法撤消", + "Cannot undo refactoring": "无法撤消重构", + "Cannot preview replacements": "无法预览替换", "Caret blinking (ms):": "光标闪烁(毫秒):", "Caret column number (1-based)": "光标所在列号(从 1 开始)", "Caret line number (1-based)": "光标所在行号(从 1 开始)", @@ -1603,6 +1605,7 @@ "Refactor This": "重构此项", "Refactor This…": "重构此项…", "Refactoring undone": "重构已撤消", + "Refactoring applied": "重构已应用", "Reformat Code": "重新格式化代码", "Prettier path": "Prettier 路径", "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。",