diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 085a9e29..09cb1b05 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -21,6 +21,51 @@ afterEach(() => { }); describe("EditorController", () => { + it("autosaves on blur, but not while focus is in a popup over the editor", async () => { + const backend = createMemoryBackend({ "/p/a.c": "int main() {\n \n}\n" }); + const save = vi.spyOn(backend, "save"); + ctrl = new EditorController(backend); + ctrl.start(); + await ctrl.openFile("/p/a.c", { focus: false }); + const slot = ctrl.activeSlot!; + const host = document.createElement("div"); + const popup = document.createElement("div"); + popup.setAttribute("data-popup", ""); + const popupList = document.createElement("button"); + popup.appendChild(popupList); + const elsewhere = document.createElement("button"); + document.body.append(host, popup, elsewhere); + try { + const view = slot.mount(host); + ctrl.onMounted(slot); + const type = () => view.dispatch({ changes: { from: 0, insert: "x" }, userEvent: "input.type" }); + // Into a popup (⌥↵ intentions): nothing is saved while it is open. + view.focus(); + type(); + popupList.focus(); + await new Promise((r) => setTimeout(r, 20)); + expect(save).not.toHaveBeenCalled(); + // Back to the editor (popup closed): still nothing. + view.focus(); + await new Promise((r) => setTimeout(r, 20)); + expect(save).not.toHaveBeenCalled(); + // From the popup elsewhere: saved then. + popupList.focus(); + await new Promise((r) => setTimeout(r, 20)); + elsewhere.focus(); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + // Straight elsewhere: saved. + view.focus(); + type(); + elsewhere.focus(); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + } finally { + host.remove(); + popup.remove(); + elsewhere.remove(); + } + }); + it("leaves modified Enter to a non-text editor after switching from a focused file", async () => { ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "one\ntwo\n" })); ctrl.start(); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 05f12416..20874e90 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -588,7 +588,21 @@ export class EditorController implements SlotHost { onBlur(slot: EditorSlot) { setContext("editorTextFocus", false); - if (this.autoSave()) void slot.model.save("auto").catch(() => {}); + if (!this.autoSave()) return; + const save = () => void slot.model.save("auto").catch(() => {}); + if (typeof document === "undefined") return save(); + // A popup (intentions, completion, …) belongs to the editor: saving under it would change + // the text (save cleanup) its items were computed for. Save once focus leaves it elsewhere. + setTimeout(() => { + if (slot.view?.hasFocus) return; + if (!inPopup(document.activeElement)) return save(); + const onFocusIn = (e: FocusEvent) => { + if (inPopup(e.target)) return; + document.removeEventListener("focusin", onFocusIn); + if (!slot.view?.hasFocus) save(); + }; + document.addEventListener("focusin", onFocusIn); + }); } onSelection(slot: EditorSlot, navigatedFrom?: { line: number; col: number }) { @@ -1892,3 +1906,7 @@ function revealLabel(): string { const os = usePlatform.getState().os; return os === "macos" ? "Finder" : os === "windows" ? "Explorer" : "Files"; } + +function inPopup(target: EventTarget | null): boolean { + return target instanceof Element && !!target.closest("[data-popup]"); +}