дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(refactor): preserve versions and recover multi-file workspace edits (#488)
Multi-file refactorings could drop LSP document versions, overwrite content changed after a preview, or leave earlier files saved after a later write failed. Workspace edits now keep server versions and preview fingerprints, are validated on a staging copy, snapshot disk and open buffers, and roll back on commit failure (retaining recovery files if rollback fails). The whole edit is one Undo/Redo step in editors and in the notification. Version reads and transaction work run off the async workers. Closes #461. Co-authored-by: shohruh-genius <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
46 files changed
+2982
-136
No files matched your search
@@ -36,12 +36,21 @@ pub enum IdeEvent {
|
||||
base_version: u32,
|
||||
version: u32,
|
||||
changes: Vec<TextChange>,
|
||||
/// 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<crate::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).
|
||||
DocLanguage {
|
||||
|
||||
@@ -97,6 +97,16 @@ pub struct TextEdit {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
/// Content fingerprints captured before presenting an edit for confirmation.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub preconditions: Vec<WorkspaceEditPrecondition>,
|
||||
}
|
||||
|
||||
#[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<TextEdit> },
|
||||
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<TextEdit>,
|
||||
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
version: Option<i32>,
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<crate::Project>,
|
||||
doc: DocId,
|
||||
version: u32,
|
||||
mut actions: Vec<CodeAction>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
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<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
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<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
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<Renamed> {
|
||||
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<WorkspaceEdit> {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Mutex<Vec<IdeEvent>>> = 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<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
release: Mutex<mpsc::Receiver<()>>,
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<TextEdit> = 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<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits };
|
||||
let file =
|
||||
|p: &str, edits: Vec<TextEdit>| 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::<i32>)));
|
||||
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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -468,7 +468,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec<u32> {
|
||||
.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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<String> + 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<Mutex<workspace_edit::History>>,
|
||||
workspace_edit_active: std::sync::atomic::AtomicBool,
|
||||
registry: Mutex<Registry>,
|
||||
observers: RwLock<Vec<Arc<dyn DocumentObserver>>>,
|
||||
sink: SharedSink,
|
||||
@@ -96,6 +101,8 @@ pub trait DocumentObserver: Send + Sync {
|
||||
fn closed(&self, _doc: DocId, _path: &Path) {}
|
||||
}
|
||||
|
||||
mod workspace_edit;
|
||||
|
||||
type Slot = Arc<Mutex<DocState>>;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -111,6 +118,7 @@ struct RegEntry {
|
||||
keys: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[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<DetectLanguage>) -> 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<T>(&self, doc: DocId, f: impl FnOnce(&mut DocState) -> IdeResult<T>) -> IdeResult<T> {
|
||||
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<DocumentInfo> {
|
||||
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<DocumentInfo> {
|
||||
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<u32> {
|
||||
self.with_doc(doc, |st| Ok(st.doc.version()))
|
||||
}
|
||||
|
||||
/// Ids of all open documents.
|
||||
pub fn open_documents(&self) -> Vec<DocId> {
|
||||
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<DocId> {
|
||||
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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -89,7 +89,7 @@ fn write_text(documents: &Documents, path: &Path, text: &str) -> IdeResult<()> {
|
||||
fn edited_paths(edit: &WorkspaceEdit) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = 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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Mutex<Vec<IdeEvent>>>) {
|
||||
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<Vec<IdeEvent>>) -> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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
|
||||
|
||||
@@ -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<WorkspaceEdit> {
|
||||
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
|
||||
|
||||
@@ -228,6 +228,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -210,6 +210,7 @@ pub fn run() {
|
||||
let _ = &early_opens;
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.host.clear_workspace_edit_history_on_exit();
|
||||
state.host.shutdown_notebooks_on_exit();
|
||||
}
|
||||
// Cleanup must finish before update/restart replaces this process.
|
||||
|
||||
+18
-2
@@ -426,6 +426,9 @@ export const commands = {
|
||||
lspRestart: (server: ServerId) => typedError<null, IdeError>(__TAURI_INVOKE("lsp_restart", { server })),
|
||||
/** Applies a (previewed) workspace edit, e.g. after rename confirmation. */
|
||||
applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError<null, IdeError>(__TAURI_INVOKE("apply_workspace_edit", { edit })),
|
||||
prepareWorkspaceEdit: (edit: WorkspaceEdit) => typedError<WorkspaceEdit, IdeError>(__TAURI_INVOKE("prepare_workspace_edit", { edit })),
|
||||
undoWorkspaceEdit: (id: number) => typedError<null, IdeError>(__TAURI_INVOKE("undo_workspace_edit", { id })),
|
||||
redoWorkspaceEdit: (id: number) => typedError<null, IdeError>(__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 = {
|
||||
|
||||
@@ -619,3 +619,37 @@ export function codePreviewExtensions(path: string): Promise<Extension> {
|
||||
export function _provideCodePreview(provider: ((path: string) => Promise<Extension>) | null) {
|
||||
codePreviewProvider = provider;
|
||||
}
|
||||
|
||||
let workspaceHistory: ((id: number, redo: boolean) => Promise<void>) | null = null;
|
||||
|
||||
/** Undo/redo a refactoring through the same history used by editor commands. */
|
||||
export function workspaceEditHistory(id: number, redo = false): Promise<void> {
|
||||
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>): () => 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);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
docs: Map<DocId, MemDoc>;
|
||||
@@ -582,13 +583,13 @@ export function createMemoryBackend(files: Record<string, string> = {}): 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);
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, DocModel>();
|
||||
readonly byId = new Map<DocId, DocModel>();
|
||||
private opening = new Map<string, Promise<DocModel>>();
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<unknown> | 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);
|
||||
|
||||
@@ -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<NotificationService["notify"]>[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");
|
||||
});
|
||||
@@ -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<Marker>();
|
||||
|
||||
export interface WorkspaceHistoryTarget {
|
||||
path: string;
|
||||
state(): EditorState;
|
||||
dispatch(tr: Transaction): void;
|
||||
}
|
||||
|
||||
export class WorkspaceHistory {
|
||||
private targets = new Set<WorkspaceHistoryTarget>();
|
||||
private records = new Map<number, { paths: string[]; undone: boolean }>();
|
||||
private project = useProject.getState().project;
|
||||
private busy = false;
|
||||
private moving = false;
|
||||
|
||||
constructor(
|
||||
private synced: () => Promise<void>,
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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<ScopeChoice>(() => 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<Set<string>>(() => 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) => <DuplicatesDialo
|
||||
export const showCodeCleanupDialog = () => openDialog((close) => <CodeCleanupDialog onClose={close} />);
|
||||
export const showStructuralDialog = (replace: boolean) =>
|
||||
openDialog((close) => <StructuralDialog replace={replace} onClose={close} />);
|
||||
export const showReplacePreview = (files: StructuralFile[]) =>
|
||||
openDialog((close) => <ReplacePreviewDialog files={files} onClose={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) => (
|
||||
<ReplacePreviewDialog files={files} preconditions={prepared.preconditions} onClose={close} />
|
||||
));
|
||||
} catch (error) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot preview replacements",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, prepareWorkspaceEdit: mocks.prepare, applyWorkspaceEdit: mocks.apply },
|
||||
};
|
||||
});
|
||||
vi.mock("@/editor/api", async (original) => ({
|
||||
...(await original<typeof import("@/editor/api")>()),
|
||||
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(<AnalysisDialogHost />);
|
||||
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(<AnalysisDialogHost />);
|
||||
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();
|
||||
});
|
||||
@@ -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<void> {
|
||||
try {
|
||||
await whenAllSynced();
|
||||
await call(commands.applyWorkspaceEdit(edit));
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<NotificationService["notify"]>[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<void>((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<typeof previous> });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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<typeof useProject.getState>["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;
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
]}
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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<string, number | null | undefined>();
|
||||
const guards: WorkspaceEdit["files"] = [];
|
||||
const byPath = new Map<string, Extract<WorkspaceEdit["files"][number], { kind: "edit" }>["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). */
|
||||
|
||||
@@ -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。指定路径将覆盖自动检测。",
|
||||
|
||||
Reference in new issue
Заблокувати користувача