From 64efbf413e4ea35d9f59f9d8f26017ca17a8bbf9 Mon Sep 17 00:00:00 2001 From: Chauncey <170892059+xaniexane@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:01:47 +0000 Subject: [PATCH 1/2] fix(save): make format-on-save await the formatter and target the saved file - Await the format command so the document write captures the formatted content instead of racing an async formatter - Only format when the file being saved is the active file: formatters operate on the active editor document, so a tab switch mid-save could previously rewrite the wrong file - Toggle markChanged on the file being saved, not whatever tab is active - Abort the save when the formatter fails instead of writing unformatted content and reporting success - acode.format() now returns null (not a failure) when no formatter is configured, so format-on-save keeps working for unformatted file types; the format command propagates the tri-state result Fixes #2900 --- src/lib/acode.js | 11 ++++++++++- src/lib/commands.js | 1 + src/lib/saveFile.js | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/lib/acode.js b/src/lib/acode.js index f36c7401a..935694cf9 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -826,6 +826,13 @@ class Acode { appSettings.update(false); } + /** + * Formats the active file with the configured formatter. + * @param {boolean} [selectIfNull] open the formatter picker when no formatter is configured + * @returns {Promise} `true` when formatting ran, `false` + * when the formatter failed, `null` when no formatter is configured + * (not a failure; callers should not treat it as one) + */ async format(selectIfNull = true) { const file = editorManager.activeFile; if (!file || file.type !== "editor") return false; @@ -858,7 +865,9 @@ class Acode { } else { toast(strings["please select a formatter"]); } - return false; + // No formatter configured: not a failure, so report `null` + // instead of `false` (both are falsy for existing callers). + return null; } try { diff --git a/src/lib/commands.js b/src/lib/commands.js index de3a005a8..efbcbe51c 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -727,6 +727,7 @@ export default { // Restore cursor position after formatting (pos.row is now 1-based) editor.gotoLine(pos.row, pos.column); } + return didFormat; }, async eol() { if (editorManager.activeFile?.type !== "editor") return; diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 0f43feaac..166b1ee41 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -172,11 +172,22 @@ async function saveFile( } if (appSettings.value.formatOnSave) { - editorManager.activeFile.markChanged = false; + // Formatters operate on the active editor document. If the user + // switched tabs while an async save step was pending, formatting + // here would rewrite the wrong file, so only format when the file + // being saved is the active one. + file.markChanged = false; try { - acode.exec("format", false); + if (editorManager.activeFile === file) { + // Await the formatter so the write below captures the + // formatted document. A failed format aborts the save; + // `null` means no formatter is configured, which is not + // a failure. + const formatted = await acode.exec("format", false); + if (formatted === false) return false; + } } finally { - editorManager.activeFile.markChanged = true; + file.markChanged = true; } } From a1fa646684ee35dcf2ffb4e7975aa7ee2fdbe2d6 Mon Sep 17 00:00:00 2001 From: Chauncey <170892059+xaniexane@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:18:59 +0000 Subject: [PATCH 2/2] fix(save): harden format-on-save against tab-switch races and failure masking - Propagate formatter.format()'s boolean through acode.format() (true = ran, false = failed, null = no formatter) so a handled formatter failure aborts the save instead of writing unformatted content as a successful save. - Revalidate the active file after the formatter resolves in acode.format(); a mid-format tab switch now reports failure. - Abort the format dispatch itself when the tab switched mid-format: prettierFormatter and LSP formatDocument return false without writing instead of dispatching into the newly active document. - Run formatting before Save As/new-file target mutations (file creation, URI reassignment, recents updates) so a failed format aborts before any of them happen. --- src/cm/lsp/clientManager.ts | 7 ++++++ src/lib/acode.js | 12 ++++++++-- src/lib/prettierFormatter.js | 5 +++++ src/lib/saveFile.js | 43 +++++++++++++++++++----------------- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index efd9cb284..351745872 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -538,6 +538,13 @@ export class LspClientManager { plugin.client.sync(); return true; } + // The user may have switched tabs while the formatting request was + // in flight; never apply this file's edits to another file's + // document. Abort without writing instead. + const em = (globalThis as Record).editorManager as + | { activeFile?: unknown } + | undefined; + if (em && file && em.activeFile !== file) return false; const applied = applyTextEdits(plugin, view, edits); if (applied) { plugin.client.sync(); diff --git a/src/lib/acode.js b/src/lib/acode.js index 935694cf9..39a447f62 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -871,8 +871,16 @@ class Acode { } try { - await formatter.format(); - return true; + // Propagate the formatter's own result instead of assuming + // success: formatters resolve `false` for handled failures + // (e.g. Prettier failing to parse, LSP reporting failure). + const didFormat = (await formatter.format()) !== false; + // The formatter dispatches through the shared editor view; if + // the user switched tabs while it was awaiting, its edits may + // have targeted the wrong document, so report failure instead + // of success and let callers abort. + if (editorManager.activeFile !== file) return false; + return didFormat; } catch (error) { helpers.error(error); return false; diff --git a/src/lib/prettierFormatter.js b/src/lib/prettierFormatter.js index 7200e5646..505a217b9 100644 --- a/src/lib/prettierFormatter.js +++ b/src/lib/prettierFormatter.js @@ -98,6 +98,11 @@ export async function formatActiveFileWithPrettier() { if (formatted === source) return true; + // The user may have switched tabs while the config/format awaits + // resolved; never dispatch into a document that is no longer this + // file's. Abort without writing instead. + if (editorManager?.activeFile !== file) return false; + editor.dispatch({ changes: { from: 0, diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 166b1ee41..f42e33d11 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -117,6 +117,29 @@ async function saveFile( let saved = false; try { + if (appSettings.value.formatOnSave) { + // Format the in-memory content BEFORE creating/reassigning the + // save target below: a failed format aborts the save before any + // file creation, URI reassignment, or recents update happens. + // Formatters operate on the active editor document. If the user + // switched tabs while an async save step was pending, formatting + // here would rewrite the wrong file, so only format when the file + // being saved is the active one. + file.markChanged = false; + try { + if (editorManager.activeFile === file) { + // Await the formatter so the write below captures the + // formatted document. A failed format aborts the save; + // `null` means no formatter is configured, which is not + // a failure. + const formatted = await acode.exec("format", false); + if (formatted === false) return false; + } + } finally { + file.markChanged = true; + } + } + if (isSaveAs || newUrl) { // if save as or new file const fileUri = Url.join(newUrl, file.filename); @@ -171,26 +194,6 @@ async function saveFile( } } - if (appSettings.value.formatOnSave) { - // Formatters operate on the active editor document. If the user - // switched tabs while an async save step was pending, formatting - // here would rewrite the wrong file, so only format when the file - // being saved is the active one. - file.markChanged = false; - try { - if (editorManager.activeFile === file) { - // Await the formatter so the write below captures the - // formatted document. A failed format aborts the save; - // `null` means no formatter is configured, which is not - // a failure. - const formatted = await acode.exec("format", false); - if (formatted === false) return false; - } - } finally { - file.markChanged = true; - } - } - const savedDoc = file.session?.doc || null; const savedVersion = file.docVersion; const data = getDocText(savedDoc);