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]>
Цей коміт міститься в:
l0ng-aiтаClaude Opus 5.5 authored and GitHub committed 2026-11-03 07:54:34 +08:00
1 джерело d9929bbe44
коміт 0a24b79b50
9 files changed
+390 -30

No files matched your search

+10 -8
Переглянути файл
@@ -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
}
+16 -9
Переглянути файл
@@ -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
}
+10 -3
Переглянути файл
@@ -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
}
+240
Переглянути файл
@@ -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;");
}
+25 -5
Переглянути файл
@@ -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 {
+31
Переглянути файл
@@ -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() }] }
}
+1 -1
Переглянути файл
@@ -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`
+55 -1
Переглянути файл
@@ -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");
+2 -3
Переглянути файл
@@ -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(