diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index fb55f6f0f..7c56cd3a3 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -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 } diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index eae55b6b9..d3913c0af 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -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 { 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 } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a42889a19..9b4dda808 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -682,17 +682,24 @@ impl Project { self.with_repo_at(None, f) } + pub(crate) fn with_disk_sync(&self, f: impl FnOnce() -> IdeResult) -> IdeResult { + 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( &self, at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { - 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 } diff --git a/crates/ide-host/tests/vcs_documents.rs b/crates/ide-host/tests/vcs_documents.rs new file mode 100644 index 000000000..b6ca6fbec --- /dev/null +++ b/crates/ide-host/tests/vcs_documents.rs @@ -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, + 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", "test@example.com"]); + 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;"); +} diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 8ea241afb..dbcee862b 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -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 = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect(); let paths: Vec = 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 { diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 02370eed5..16670ce1e 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -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() }] } } diff --git a/src/editor/api.ts b/src/editor/api.ts index 671f0d9bc..fb5554b03 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -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` diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 6ee9a7d91..1b066027e 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -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"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9c70e8f61..a6bd1f182 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -569,8 +569,7 @@ export class DocModel { save(reason: "explicit" | "auto"): Promise { 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(