дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(editor): skip clean saves and refresh buffers after disk operations (#920)
Root cause, two parts: - An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did. - After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling. Fixes #893 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
1 джерело
d9929bbe44
коміт
0a24b79b50
9 files changed
+390
-30
No files matched your search
@@ -56,14 +56,16 @@ pub async fn copy_merge(
|
||||
if overwrite {
|
||||
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
project.with_disk_sync(|| {
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore(
|
||||
}
|
||||
}
|
||||
apply_git_executable(&project.settings.effective());
|
||||
let result = ide_vcs::add_to_ignore(&paths, destination);
|
||||
let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination));
|
||||
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
|
||||
project.watcher.reload_ignore_rules();
|
||||
for (_, watcher) in project.content_watchers.lock().iter() {
|
||||
@@ -322,7 +322,7 @@ pub async fn vcs_commit(
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| {
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
@@ -729,7 +729,7 @@ pub async fn vcs_add_worktree(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -846,7 +846,7 @@ pub async fn vcs_apply_patch(
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
@@ -948,13 +948,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt
|
||||
}
|
||||
|
||||
/// Create Patch › Save to file.
|
||||
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let write = || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
};
|
||||
match project {
|
||||
Some(project) => project.with_disk_sync(write),
|
||||
None => write(),
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -682,17 +682,24 @@ impl Project {
|
||||
self.with_repo_at(None, f)
|
||||
}
|
||||
|
||||
pub(crate) fn with_disk_sync<T>(&self, f: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
|
||||
let result = f();
|
||||
// Failed operations can also leave changed files (for example merge conflicts).
|
||||
self.documents.sync_after_disk_change();
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
|
||||
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
|
||||
/// snapshot of every locally changed file before, and a label after.
|
||||
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
|
||||
/// Clean buffers follow the resulting files before returning; `None` omits the history labels.
|
||||
pub fn with_repo_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_repo_at(at, f) };
|
||||
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
@@ -701,7 +708,7 @@ impl Project {
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = self.with_repo_at(at, f);
|
||||
let result = self.with_disk_sync(|| self.with_repo_at(at, f));
|
||||
self.history_label(&format!("After {name}"));
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use ide_core::{ChangeSet, NullSink, TextChange};
|
||||
use ide_host::{Host, HostConfig, Project, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0";
|
||||
const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0";
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
path: std::path::PathBuf,
|
||||
doc: ide_core::DocId,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// The command must synchronize documents even when watcher delivery is excluded.
|
||||
let path = root.join("node_modules/query.sql");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
git(&root, &["add", "-f", "node_modules/query.sql"]);
|
||||
git(&root, &["commit", "-m", "original"]);
|
||||
git(&root, &["switch", "-c", "other"]);
|
||||
std::fs::write(&path, OTHER).unwrap();
|
||||
git(&root, &["commit", "-am", "other"]);
|
||||
git(&root, &["switch", "main"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let doc = project.documents.open(&path).unwrap().id;
|
||||
Self { host, project, path, doc, _temp: temp }
|
||||
}
|
||||
|
||||
fn saved_edit(&self, text: &str) {
|
||||
self.project.documents.replace_all(self.doc, text).unwrap();
|
||||
self.project.documents.save(self.doc).unwrap();
|
||||
}
|
||||
|
||||
fn assert_clean(&self, text: &str) {
|
||||
let snapshot = self.project.documents.snapshot(self.doc).unwrap();
|
||||
assert_eq!(snapshot.text, text);
|
||||
assert!(!snapshot.dirty);
|
||||
self.project.documents.save_all().unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_project("main");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn checkout_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rollback_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
f.saved_edit(&format!("{ORIGINAL}\n"));
|
||||
vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
let edited = format!("{ORIGINAL}\n");
|
||||
f.saved_edit(&edited);
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
|
||||
f.assert_clean(&edited);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn applying_patch_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
std::fs::write(&f.path, OTHER).unwrap();
|
||||
let patch = git(&f.project.root, &["diff"]);
|
||||
git(&f.project.root, &["restore", "."]);
|
||||
vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn writing_patch_file_reloads_its_clean_open_document() {
|
||||
let f = Fixture::new().await;
|
||||
vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn directory_compare_overwrite_reloads_clean_open_documents() {
|
||||
let f = Fixture::new().await;
|
||||
let base = f.project.root.parent().unwrap();
|
||||
let source = base.join("source");
|
||||
let destination = base.join("destination");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::create_dir_all(&destination).unwrap();
|
||||
std::fs::write(source.join("query.sql"), OTHER).unwrap();
|
||||
let path = destination.join("query.sql");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let doc = f.project.documents.open(&path).unwrap();
|
||||
ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap();
|
||||
let snapshot = f.project.documents.snapshot(doc.id).unwrap();
|
||||
assert_eq!(snapshot.text, OTHER);
|
||||
assert!(!snapshot.dirty);
|
||||
f.project.documents.save_all().unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_working_tree_operation_synchronizes_partial_disk_changes() {
|
||||
let f = Fixture::new().await;
|
||||
let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| {
|
||||
std::fs::write(&f.path, OTHER).unwrap();
|
||||
Err(ide_core::IdeError::invalid("merge stopped with conflicts"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn working_tree_operation_reloads_same_size_same_timestamp_changes() {
|
||||
let f = Fixture::new().await;
|
||||
let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap();
|
||||
let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20");
|
||||
f.project
|
||||
.with_repo_recorded(None, Some("Git checkout"), |_| {
|
||||
std::fs::write(&f.path, &replacement).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&f.path)
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(modified))
|
||||
.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
f.assert_clean(&replacement);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn working_tree_operation_does_not_discard_unsaved_edits() {
|
||||
let f = Fixture::new().await;
|
||||
let version = f.project.documents.snapshot(f.doc).unwrap().version;
|
||||
f.project
|
||||
.documents
|
||||
.edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] })
|
||||
.unwrap();
|
||||
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
|
||||
let snapshot = f.project.documents.snapshot(f.doc).unwrap();
|
||||
assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}"));
|
||||
assert!(snapshot.dirty);
|
||||
assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn adding_local_exclude_reloads_open_ignore_document() {
|
||||
let f = Fixture::new().await;
|
||||
let path = f.project.root.join(".git/info/exclude");
|
||||
let doc = f.project.documents.open(&path).unwrap();
|
||||
let ignored = f.project.root.join("generated.txt");
|
||||
std::fs::write(&ignored, "generated\n").unwrap();
|
||||
vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap();
|
||||
let disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(disk.contains("/generated.txt"));
|
||||
assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn commit_hook_reloads_clean_open_documents() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new().await;
|
||||
f.saved_edit(&format!("{ORIGINAL}\n"));
|
||||
git(&f.project.root, &["add", "-u"]);
|
||||
let hook = f.project.root.join(".git/hooks/pre-commit");
|
||||
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap();
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
vcs::vcs_commit(
|
||||
&f.host,
|
||||
"main",
|
||||
None,
|
||||
"commit with hook".into(),
|
||||
ide_vcs::CommitOptions { run_hooks: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
f.assert_clean("SELECT hook;");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn worktree_checkout_hook_reloads_clean_open_documents() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new().await;
|
||||
let hook = f.project.root.join(".git/hooks/post-checkout");
|
||||
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap();
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let linked = f.project.root.parent().unwrap().join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap();
|
||||
f.assert_clean("SELECT hook;");
|
||||
}
|
||||
@@ -821,6 +821,17 @@ impl Documents {
|
||||
/// Re-checks open file buffers, including files outside the watched roots.
|
||||
/// Call from a blocking worker; unchanged files only need their metadata.
|
||||
pub fn sync_from_disk(&self) {
|
||||
self.sync_open_documents(false);
|
||||
}
|
||||
|
||||
/// Refreshes clean buffers after an IDE operation rewrites files. Such an
|
||||
/// operation can preserve both file length and the timestamp's millisecond.
|
||||
/// The open document's selected codec is retained.
|
||||
pub fn sync_after_disk_change(&self) {
|
||||
self.sync_open_documents(true);
|
||||
}
|
||||
|
||||
fn sync_open_documents(&self, force_clean: bool) {
|
||||
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
||||
let paths: Vec<PathBuf> = slots
|
||||
.iter()
|
||||
@@ -830,13 +841,17 @@ impl Documents {
|
||||
.then(|| st.path.clone())
|
||||
})
|
||||
.collect();
|
||||
self.on_disk_changed(&paths);
|
||||
self.reload_from_disk(&paths, force_clean);
|
||||
}
|
||||
|
||||
/// Re-reads from disk after an external change. Clean documents reload
|
||||
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
|
||||
/// Changes matching our own last save are ignored.
|
||||
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
|
||||
self.reload_from_disk(paths, false);
|
||||
}
|
||||
|
||||
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
|
||||
for path in paths {
|
||||
let Some(id) = self.by_path(path) else { continue };
|
||||
let result = self.with_doc(id, |st| {
|
||||
@@ -845,7 +860,7 @@ impl Documents {
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
|
||||
let mtime = ide_vfs::mtime_ms(&meta);
|
||||
if mtime == st.disk_mtime && meta.len() == st.disk_len {
|
||||
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
|
||||
return Ok(());
|
||||
}
|
||||
if st.dirty {
|
||||
@@ -863,9 +878,14 @@ impl Documents {
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
let loaded = if force_clean {
|
||||
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
|
||||
} else {
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
loaded
|
||||
};
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
if loaded.binary || st.binary {
|
||||
|
||||
@@ -57,6 +57,37 @@ fn fixture() -> Fixture {
|
||||
Fixture { _tmp: tmp, root, docs, events, rec }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_disk_changes_preserve_the_open_documents_encoding() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("query.sql");
|
||||
let original = "SELECT '中文';\r\n";
|
||||
fs::write(&path, ide_vfs::encode(original, "gbk", false).unwrap()).unwrap();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.project_fallback = "gbk".into();
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
assert_eq!(doc.encoding, "gbk");
|
||||
defaults.project_fallback = "windows-1252".into();
|
||||
f.docs.set_file_defaults(defaults);
|
||||
f.docs.sync_after_disk_change();
|
||||
let unchanged = f.docs.snapshot(doc.id).unwrap();
|
||||
assert_eq!(unchanged.text, doc.text);
|
||||
assert_eq!(unchanged.encoding, "gbk");
|
||||
assert!(!unchanged.dirty);
|
||||
let changed = "SELECT '世界';\r\n";
|
||||
let bytes = ide_vfs::encode(changed, "gbk", false).unwrap();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
f.docs.sync_after_disk_change();
|
||||
let reloaded = f.docs.snapshot(doc.id).unwrap();
|
||||
assert_eq!(reloaded.text, changed.replace("\r\n", "\n"));
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert_eq!(reloaded.line_ending, LineEnding::CrLf);
|
||||
assert!(!reloaded.dirty);
|
||||
f.docs.save_all().unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
fn change(from: u32, to: u32, insert: &str) -> ChangeSet {
|
||||
ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] }
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export function _emitEditorEvent(e: EditorEvent) {
|
||||
// --- save participants ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Runs before a document is written (format on save, organise imports…).
|
||||
* Runs before a modified document is written (format on save, organise imports…).
|
||||
* Participants may edit the document through `handle.view` (normal
|
||||
* transactions); editor/core waits for them, then trims whitespace / adds the
|
||||
* final newline per settings, flushes the edit pipeline and saves. `handle`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { language as languageFacet } from "@codemirror/language";
|
||||
import { remoteChange } from "@/editor/api";
|
||||
import { registerSaveParticipant, remoteChange } from "@/editor/api";
|
||||
import { createMemoryBackend, type MemoryBackend } from "../backend";
|
||||
import { _resetLanguages } from "../cm/languages";
|
||||
import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document";
|
||||
@@ -88,6 +88,60 @@ describe("DocumentManager + slots", () => {
|
||||
expect(useDocMeta.getState()["/p/a.txt"]?.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["explicit", "auto"] as const)(
|
||||
"%s save leaves untouched documents byte-for-byte unchanged",
|
||||
async (reason) => {
|
||||
const files = {
|
||||
"/p/src/main/resources/sql/no-final.sql": "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0",
|
||||
"/p/trailing.sql": "SELECT id \nFROM patients;\t \n",
|
||||
"/p/crlf.sql": "SELECT id\r\nFROM patients;\r\n",
|
||||
"/p/Untouched.java": "class Untouched { \n\tint id;\n}",
|
||||
"/p/blank.sql": "SELECT 1;\n\n\n",
|
||||
"/p/edited.java": "class Edited {}\n",
|
||||
};
|
||||
const { backend, manager } = setup(files, {
|
||||
editor: { general: { stripTrailingScope: "all", removeTrailingBlankLines: true } },
|
||||
});
|
||||
const participant = vi.fn();
|
||||
const unregister = registerSaveParticipant({ id: "test.save", beforeSave: participant });
|
||||
const save = vi.spyOn(backend, "save");
|
||||
try {
|
||||
const slots = [];
|
||||
for (const path of Object.keys(files)) slots.push(new EditorSlot(await manager.acquire(path), path, host));
|
||||
const edited = slots.at(-1)!;
|
||||
edited.dispatchLocal({ changes: { from: 0, insert: "// edited\n" } });
|
||||
await manager.saveAll(reason);
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
if (path !== "/p/edited.java") expect(backend.files.get(path), path).toBe(text);
|
||||
}
|
||||
expect(backend.files.get("/p/edited.java")).toBe("// edited\nclass Edited {}\n");
|
||||
expect(save.mock.calls).toEqual([[edited.model.doc]]);
|
||||
expect(participant).toHaveBeenCalledTimes(reason === "explicit" ? 1 : 0);
|
||||
if (reason === "explicit") expect(participant.mock.calls[0]![0].path).toBe("/p/edited.java");
|
||||
await manager.saveAll(reason);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregister();
|
||||
manager.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("checks dirty state when each queued save starts", async () => {
|
||||
const { backend, manager } = setup({ "/p/query.sql": "SELECT 1" });
|
||||
const model = await manager.acquire("/p/query.sql");
|
||||
const slot = new EditorSlot(model, "t1", host);
|
||||
slot.dispatchLocal({ changes: { from: 7, to: 8, insert: "2" } });
|
||||
const save = vi.spyOn(backend, "save");
|
||||
try {
|
||||
await Promise.all([model.save("explicit"), model.save("explicit"), model.save("auto")]);
|
||||
expect(backend.files.get(model.path)).toBe("SELECT 2\n");
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
manager.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the backend document when the last slot goes away", async () => {
|
||||
const { backend, manager } = setup({ "/p/a.txt": "x" });
|
||||
const model = await manager.acquire("/p/a.txt");
|
||||
|
||||
@@ -569,8 +569,7 @@ export class DocModel {
|
||||
|
||||
save(reason: "explicit" | "auto"): Promise<void> {
|
||||
const run = async () => {
|
||||
if (this.closed || this.meta.readOnly) return;
|
||||
if (reason === "auto" && !this.meta.dirty) return;
|
||||
if (this.closed || this.meta.readOnly || !this.meta.dirty) return;
|
||||
const slot = this.primarySlot();
|
||||
if (reason === "explicit") {
|
||||
for (const p of _saveParticipants()) {
|
||||
@@ -764,7 +763,7 @@ export class DocumentManager {
|
||||
for (const m of this.byPath.values()) m.applySettings();
|
||||
}
|
||||
|
||||
/** Saves every document; failures are notified, and an explicit Save All also rejects with them. */
|
||||
/** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */
|
||||
async saveAll(reason: "explicit" | "auto") {
|
||||
const errors: unknown[] = [];
|
||||
await Promise.all(
|
||||
|
||||
Reference in new issue
Заблокувати користувача